init
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/build
|
||||||
|
**/coverage
|
||||||
|
**/logs
|
||||||
|
**/data/db.json
|
||||||
|
**/data/test-db.json
|
||||||
37
Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Backend
|
||||||
|
FROM node:18-alpine AS backend
|
||||||
|
WORKDIR /usr/src/app/backend
|
||||||
|
COPY backend/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
RUN npm install typescript
|
||||||
|
COPY backend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
FROM node:18-alpine AS frontend
|
||||||
|
WORKDIR /usr/src/app/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Final image
|
||||||
|
FROM node:18-alpine
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Create data directory for database persistence
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
# Set environment variable for data directory
|
||||||
|
ENV DATA_DIR=/data
|
||||||
|
|
||||||
|
COPY --from=backend /usr/src/app/backend/dist ./backend/dist
|
||||||
|
COPY --from=backend /usr/src/app/backend/package.json ./backend/package.json
|
||||||
|
COPY --from=backend /usr/src/app/backend/node_modules ./backend/node_modules
|
||||||
|
COPY --from=frontend /usr/src/app/frontend/build ./frontend/build
|
||||||
|
|
||||||
|
# Create volume mount point for persistent data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "backend/dist/index.js"]
|
||||||
1
INITIAL_BUILD_PROMPT.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Create a web application designed for tracking turns in games, specifically optimized for Civilization 6 Play by Cloud, but with a generic and user-friendly interface to allow for other uses.Core Functionality:Lobby Creation: Users can create new lobbies.Player Limit: Each lobby should support between 2 and 10 players.Turn Order Display: Clearly display the current player's turn and the full turn order.Turn Advancement:The current player can click an "End Turn" button to advance the turn.Lobby administrators can manually advance the turn.Turn Order Management (Admin): Lobby administrators must be able to drag-and-drop players to reorder the turn sequence.Current Turn Pointer Management (Admin): Lobby administrators must be able to drag-and-drop a visual pointer (e.g., an arrow) to explicitly set whose turn it is.Lobby Invitation:Each lobby will have a unique, persistent invite link.Users must log in or create an account before they can join a lobby via an invite link.User Lobby Membership: A single user can create and be a member of multiple lobbies simultaneously.User Departure: If a user leaves a lobby, their name should remain in the turn order but be skipped during turn progression. Lobby administrators should also have the option to fully remove a player from the lobby.Lobby Admin Controls: In addition to turn management, lobby administrators should be able to:Rename the lobby.Set a password for the lobby.Kick a player from the lobby.Notifications (CRITICAL FEATURE):The application must provide browser push notifications when it is a user's turn.The frontend should poll the server for turn updates every 5 minutes.This feature should be implemented using Service Workers to enable installability and background notifications.User Authentication:Implement a basic username and password authentication system (no email required for registration or recovery).Super Admin: A "super admin" role must exist, whose credentials are set via environment variables in the Docker container. The super admin's sole function is to reset user passwords if they are lost. No other password recovery mechanism is needed or desired.Data Storage:Client-side: Use IndexedDB for any necessary client-side data persistence.Backend: Use a file-based JSON database for backend data storage to ensure data persists across server restarts.Progressive Web Application (PWA) Features:The application should be a PWA, supporting installability to the home screen and, most importantly, push notifications via Service Workers.Infrastructure & Deployment:The entire application (backend and frontend) should be packaged into a single Docker container.The application is intended for self-hosting on private hardware, behind an Nginx proxy manager for domain linking and SSL (no cloud-specific setup or services are required or desired).
|
||||||
117
INITIAL_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
I need help creating a prompt for Gemini CLI to develop a simple app for me. Here is what I have so far:
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a web application where users can create a lobby and invite their friends to track which persons turn it is in a game. The app should show the current users turn and the turn order. Lobbies should have invite links to join. The user that creates the lobby should be able to have "admin" control to invite people, change the turn order, or change whos turn it is. The app should be a progressive web application with a backend that tracks the state using SQLite or something equally as light, and the front end should be setup to check the server for if its the persons turn every so often (this should be configurable) and give the user a notification. There should be basic login and password, without email, just a username and a password. There should be a "super" admin that can change peoples passwords if someone loses it. The app should be easily runnable in a docker container.
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Please ask me follow up questions to clarify the point of the app, how it works, and its infrastructure to help me improve the prompt. This will be a self hosted app ran on my own hardware using nginx proxy manager to link it to a domain name with SSL, so no cloud setup.
|
||||||
|
|
||||||
|
That's a great start to your prompt! To help me refine it further and ensure the Gemini CLI can generate the most accurate and useful application for you, let's clarify a few points about the app's purpose, how it works, and its infrastructure.
|
||||||
|
|
||||||
|
Here are some follow-up questions:
|
||||||
|
|
||||||
|
About the Game & Turn Tracking:
|
||||||
|
|
||||||
|
What kind of "game" is this primarily intended for? Is it for board games, video games, tabletop role-playing games, or perhaps even real-life activities like taking turns doing chores? Knowing the context will help tailor the UI.
|
||||||
|
|
||||||
|
How many players do you typically expect in a lobby? (e.g., 2-4, 5-10, 10+). This impacts UI layout and performance considerations.
|
||||||
|
|
||||||
|
How does a turn typically "end" in your envisioned game?
|
||||||
|
|
||||||
|
Does the current player click a "Next Turn" button?
|
||||||
|
|
||||||
|
Does the lobby admin manually advance the turn?
|
||||||
|
|
||||||
|
Is there a timer that automatically advances the turn?
|
||||||
|
|
||||||
|
What kind of notification do you envision when it's a user's turn? (e.g., a browser push notification, an audible alert, an in-app visual alert, or a combination?)
|
||||||
|
|
||||||
|
What's a reasonable range for the "check the server every so often" interval? (e.g., every 5 seconds, every 30 seconds, every minute, every 5 minutes?)
|
||||||
|
|
||||||
|
Lobby & User Management:
|
||||||
|
|
||||||
|
How should the invite links work?
|
||||||
|
|
||||||
|
Should they be unique, one-time use links?
|
||||||
|
|
||||||
|
Should they be permanent links to a specific lobby that anyone with the link can join?
|
||||||
|
|
||||||
|
Do users need to be logged in before joining via an invite link, or can they create an account/log in as part of the joining process?
|
||||||
|
|
||||||
|
What happens when a user leaves a lobby? Should their name be removed from the turn order immediately, or should there be an option for the admin to remove them?
|
||||||
|
|
||||||
|
Can a single user be a member of multiple lobbies simultaneously? If so, how should the UI handle switching between them?
|
||||||
|
|
||||||
|
Admin Controls:
|
||||||
|
|
||||||
|
When an admin "changes the turn order," how granular should this control be?
|
||||||
|
|
||||||
|
Should they be able to drag-and-drop players in a list?
|
||||||
|
|
||||||
|
Should they be able to swap the positions of two specific players?
|
||||||
|
|
||||||
|
Should they be able to move a player to a specific position (e.g., "move John to position 3")?
|
||||||
|
|
||||||
|
For "changing whose turn it is," is this a direct jump to a specific player, or simply moving that player to the front of the queue?
|
||||||
|
|
||||||
|
Are there any other "admin" controls you anticipate needing for lobbies? (e.g., renaming the lobby, setting a lobby password, kicking a player, pausing the turn order?)
|
||||||
|
|
||||||
|
Authentication & Super Admin:
|
||||||
|
|
||||||
|
How will the "super admin" account be initially created or managed? (e.g., hardcoded in an environment variable, created via a special setup script, or the very first user registered becomes the super admin?)
|
||||||
|
|
||||||
|
If a user forgets their password and there's no super admin available, is there any fallback mechanism? (The prompt currently implies the super admin is the only way, just confirming this is the desired behavior).
|
||||||
|
|
||||||
|
Technical & Infrastructure:
|
||||||
|
|
||||||
|
Regarding "SQLite or something equally as light," are you open to other embedded database solutions like IndexedDB (for client-side PWA storage) or a simple file-based JSON database for the backend if it simplifies development?
|
||||||
|
|
||||||
|
When you say "Progressive Web Application," are you looking for all PWA features (offline support, installability to home screen, push notifications via service workers) or just some aspects like responsiveness and web app manifest?
|
||||||
|
|
||||||
|
For Docker, do you have a preference for a single container (backend and frontend served from the same container) or separate containers for the backend API and the frontend static files?
|
||||||
|
|
||||||
|
Your answers will help create a much more precise and effective prompt!
|
||||||
|
|
||||||
|
1. We are doing this for civilization 6 play by cloud, but the UI should be somewhat generic and friendly if there ends up being other reasons to use it.
|
||||||
|
|
||||||
|
2. between 2 and 10 people
|
||||||
|
|
||||||
|
3. The person should be able to just press a button that says their turn is over; the lobby admin can manually advance things also if things get messed up
|
||||||
|
|
||||||
|
4. a browser push notification should work great
|
||||||
|
|
||||||
|
5. Every 5 minutes
|
||||||
|
|
||||||
|
6. The invite links should be unique per lobby, but not one time use. They need to create an account or login before being able to join a lobby
|
||||||
|
|
||||||
|
7. When a user leaves a lobby, they should still show up in the turn order, but they will be skipped over. The admin can remove them also
|
||||||
|
|
||||||
|
8. A single user can be a member or start as many lobbies as they want
|
||||||
|
|
||||||
|
9. A lobby admin should be able to drag and drop the users in the turn order
|
||||||
|
|
||||||
|
10. For changing whose turn it is, the turn order of users should be drag and drop for the admin, and then there should be an arrow on the side pointing to whose turn it is, and the admin can drag and drop that also to point to whose turn it is.
|
||||||
|
|
||||||
|
11. Admins should be able to rename the lobby, setting a lobby password, or kick a player
|
||||||
|
|
||||||
|
12. The super admin can be set with environmental variables through the docker container
|
||||||
|
|
||||||
|
13. No
|
||||||
|
|
||||||
|
14. For client side storage indexeddb is great, for the backend a json file is totally fine, but it definitely needs to store to disk not just in memory in case my server gets restarted.
|
||||||
|
|
||||||
|
15. For the progressive web app I am looking for installability on the home screen and push notifications via service workers. The notifications are the most important part of this application.
|
||||||
|
|
||||||
|
16. I would prefer one simple container
|
||||||
|
|
||||||
|
That's excellent, this level of detail is exactly what's needed! I've incorporated all your clarifications into a refined prompt for the Gemini CLI. This should give it a much clearer picture of what to build.
|
||||||
|
|
||||||
|
Here's the improved prompt:
|
||||||
|
Refined Prompt for Turn Tracker Web Application
|
||||||
|
Jul 21, 6:06 PM
|
||||||
|
|
||||||
|
This prompt is now much more comprehensive and leaves less room for ambiguity. Let me know if you think of any other details!
|
||||||
147
backend/db.json
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "atheartengineer",
|
||||||
|
"password": "$2b$10$mLEaCmm/EPQMcU5C.ahobORE5Qzvt0gQ5nJnjg/61q/zEDNJcSxDW",
|
||||||
|
"createdAt": "2025-07-21T23:37:31.564Z",
|
||||||
|
"lastLoginAt": "2025-07-22T00:31:58.534Z",
|
||||||
|
"isSuper": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "superadmin",
|
||||||
|
"password": "$2b$10$QRoLFtCviy5cfPjeEITmrO8IYw//19wIDjzzkkUT/EoOqGp33zOW2",
|
||||||
|
"createdAt": "2025-07-21T23:57:50.841Z",
|
||||||
|
"lastLoginAt": "2025-07-21T23:57:50.841Z",
|
||||||
|
"isSuper": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "test",
|
||||||
|
"password": "$2b$10$N9f88RNvEJJQTRwlWpmvSOWh4mZCeU/B8r5HZa0w/SbDKGVt4KxGy",
|
||||||
|
"createdAt": "2025-07-22T00:34:35.493Z",
|
||||||
|
"lastLoginAt": "2025-07-22T00:46:35.943Z",
|
||||||
|
"isSuper": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lobbies": [
|
||||||
|
{
|
||||||
|
"id": "bfc2ab13-58a3-4c72-8d83-2484d800054e",
|
||||||
|
"name": "BooksAndStuff",
|
||||||
|
"owner": "atheartengineer",
|
||||||
|
"players": [
|
||||||
|
{
|
||||||
|
"username": "atheartengineer",
|
||||||
|
"left": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "test",
|
||||||
|
"left": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"turn": 0,
|
||||||
|
"inviteCode": "e4b34eb5-7f88-466a-934f-d813061c7127",
|
||||||
|
"createdAt": "2025-07-21T23:37:50.155Z",
|
||||||
|
"lastActivity": "2025-07-22T01:14:36.540Z",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "12158532-9bbe-4042-a3fb-77e045617078",
|
||||||
|
"timestamp": "2025-07-21T23:37:50.156Z",
|
||||||
|
"username": "atheartengineer",
|
||||||
|
"action": "Lobby created",
|
||||||
|
"details": "Lobby \"BooksAndStuff\" created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6ae6dc92-fe79-4656-a8ed-c6b90613ce2d",
|
||||||
|
"timestamp": "2025-07-22T00:31:35.433Z",
|
||||||
|
"username": "atheartengineer",
|
||||||
|
"action": "Player left",
|
||||||
|
"details": "atheartengineer left the lobby"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "432adc55-8ae0-4fef-8046-2f59d91de958",
|
||||||
|
"timestamp": "2025-07-22T00:31:39.304Z",
|
||||||
|
"username": "atheartengineer",
|
||||||
|
"action": "Player left",
|
||||||
|
"details": "atheartengineer left the lobby"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4e1fed32-a643-4020-b164-55774d270452",
|
||||||
|
"timestamp": "2025-07-22T00:46:43.607Z",
|
||||||
|
"username": "test",
|
||||||
|
"action": "Player joined",
|
||||||
|
"details": "test joined the lobby"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b559db2f-ccaa-4ed4-8320-22bf6a2ab671",
|
||||||
|
"timestamp": "2025-07-22T00:48:33.790Z",
|
||||||
|
"username": "atheartengineer",
|
||||||
|
"action": "Turn order locked",
|
||||||
|
"details": "Turn order has been locked to prevent changes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6aea3ce1-3448-4575-bff0-3a3feaea1c92",
|
||||||
|
"timestamp": "2025-07-22T00:48:37.422Z",
|
||||||
|
"username": "atheartengineer",
|
||||||
|
"action": "Turn order unlocked",
|
||||||
|
"details": "Turn order has been unlocked and can be changed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d1c0c25b-c9cf-4d2e-9adc-2982efe93468",
|
||||||
|
"timestamp": "2025-07-22T00:48:38.786Z",
|
||||||
|
"username": "atheartengineer",
|
||||||
|
"action": "Player order changed",
|
||||||
|
"details": "Turn order was reordered"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "beb4de13-307d-4cd7-9189-7ccbb7add21d",
|
||||||
|
"timestamp": "2025-07-22T00:48:40.178Z",
|
||||||
|
"username": "atheartengineer",
|
||||||
|
"action": "Player order changed",
|
||||||
|
"details": "Turn order was reordered"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c2042ebd-7fc8-4a28-82d3-e8ba02cadcc5",
|
||||||
|
"timestamp": "2025-07-22T00:48:41.686Z",
|
||||||
|
"username": "atheartengineer",
|
||||||
|
"action": "Turn order locked",
|
||||||
|
"details": "Turn order has been locked to prevent changes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "9fe87944-c0f1-4f0d-abf2-928046742946",
|
||||||
|
"timestamp": "2025-07-22T00:48:46.997Z",
|
||||||
|
"username": "atheartengineer",
|
||||||
|
"action": "Turn order unlocked",
|
||||||
|
"details": "Turn order has been unlocked and can be changed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "99915c7d-027f-4a39-850b-ab9660e0f5cd",
|
||||||
|
"timestamp": "2025-07-22T01:11:53.785Z",
|
||||||
|
"username": "atheartengineer",
|
||||||
|
"action": "Turn manually set",
|
||||||
|
"details": "Turn manually set from atheartengineer to test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "542860cf-1c7e-411c-89f0-6355aa2b5a78",
|
||||||
|
"timestamp": "2025-07-22T01:13:56.339Z",
|
||||||
|
"username": "test",
|
||||||
|
"action": "Turn ended",
|
||||||
|
"details": "Turn passed from test to atheartengineer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8e3f3c40-51c0-4268-82d9-1fad871b4756",
|
||||||
|
"timestamp": "2025-07-22T01:14:12.183Z",
|
||||||
|
"username": "atheartengineer",
|
||||||
|
"action": "Turn ended",
|
||||||
|
"details": "Turn passed from atheartengineer to test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "95516df1-b046-457e-9a90-b31200480429",
|
||||||
|
"timestamp": "2025-07-22T01:14:36.540Z",
|
||||||
|
"username": "test",
|
||||||
|
"action": "Turn ended",
|
||||||
|
"details": "Turn passed from test to atheartengineer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"locked": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
5239
backend/package-lock.json
generated
Normal file
53
backend/package.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "NODE_ENV=test jest",
|
||||||
|
"test:watch": "NODE_ENV=test jest --watch",
|
||||||
|
"build": "tsc"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"body-parser": "^2.2.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/jest": "^29.5.0",
|
||||||
|
"@types/node": "^24.0.15",
|
||||||
|
"@types/supertest": "^6.0.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@types/web-push": "^3.6.3",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.1.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"preset": "ts-jest",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"setupFilesAfterEnv": [
|
||||||
|
"<rootDir>/src/__tests__/setup.ts"
|
||||||
|
],
|
||||||
|
"testMatch": [
|
||||||
|
"**/__tests__/**/*.test.ts"
|
||||||
|
],
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"!src/**/*.d.ts",
|
||||||
|
"!src/__tests__/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
460
backend/src/__tests__/api/lobbies.test.ts
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
import request from 'supertest';
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import bodyParser from 'body-parser';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
import { lobbiesRouter } from '../../api/lobbies';
|
||||||
|
import { readDB, writeDB } from '../../db/db';
|
||||||
|
|
||||||
|
// Mock WebSocket server
|
||||||
|
const mockWss = {
|
||||||
|
clients: new Set(),
|
||||||
|
on: jest.fn(),
|
||||||
|
close: jest.fn()
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
app.use('/api/lobbies', lobbiesRouter(mockWss));
|
||||||
|
|
||||||
|
describe('Lobbies API', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clear the database before each test
|
||||||
|
await writeDB({ users: [], lobbies: [], pushSubscriptions: [] });
|
||||||
|
// Clear mock WebSocket clients
|
||||||
|
mockWss.clients.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/lobbies', () => {
|
||||||
|
it('should create a new lobby successfully', async () => {
|
||||||
|
const response = await request(app).post('/api/lobbies').send({
|
||||||
|
name: 'Test Lobby',
|
||||||
|
owner: 'testuser'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body).toHaveProperty('id');
|
||||||
|
expect(response.body).toHaveProperty('name', 'Test Lobby');
|
||||||
|
expect(response.body).toHaveProperty('owner', 'testuser');
|
||||||
|
expect(response.body).toHaveProperty('players');
|
||||||
|
expect(response.body.players).toHaveLength(1);
|
||||||
|
expect(response.body.players[0]).toEqual({ username: 'testuser', left: false });
|
||||||
|
expect(response.body).toHaveProperty('turn', 0);
|
||||||
|
expect(response.body).toHaveProperty('inviteCode');
|
||||||
|
|
||||||
|
// Verify lobby was saved to database
|
||||||
|
const db = await readDB();
|
||||||
|
expect(db.lobbies).toHaveLength(1);
|
||||||
|
expect(db.lobbies[0].name).toBe('Test Lobby');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing name or owner gracefully', async () => {
|
||||||
|
const response1 = await request(app).post('/api/lobbies').send({
|
||||||
|
name: 'Test Lobby'
|
||||||
|
// Missing owner
|
||||||
|
});
|
||||||
|
|
||||||
|
const response2 = await request(app).post('/api/lobbies').send({
|
||||||
|
owner: 'testuser'
|
||||||
|
// Missing name
|
||||||
|
});
|
||||||
|
|
||||||
|
// The API should handle these gracefully (either creating with empty values or returning errors)
|
||||||
|
expect([200, 201, 400, 500].includes(response1.status)).toBe(true);
|
||||||
|
expect([200, 201, 400, 500].includes(response2.status)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/lobbies/:id', () => {
|
||||||
|
let testLobbyId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const response = await request(app).post('/api/lobbies').send({
|
||||||
|
name: 'Test Lobby',
|
||||||
|
owner: 'testuser'
|
||||||
|
});
|
||||||
|
testLobbyId = response.body.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get lobby by ID successfully', async () => {
|
||||||
|
const response = await request(app).get(`/api/lobbies/${testLobbyId}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toHaveProperty('id', testLobbyId);
|
||||||
|
expect(response.body).toHaveProperty('name', 'Test Lobby');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent lobby', async () => {
|
||||||
|
const response = await request(app).get('/api/lobbies/nonexistent-id');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.text).toBe('Lobby not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/lobbies/user/:username', () => {
|
||||||
|
let testLobbyId1: string;
|
||||||
|
let testLobbyId2: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create test lobbies
|
||||||
|
const lobby1Response = await request(app).post('/api/lobbies').send({
|
||||||
|
name: 'Test Lobby 1',
|
||||||
|
owner: 'player1'
|
||||||
|
});
|
||||||
|
testLobbyId1 = lobby1Response.body.id;
|
||||||
|
|
||||||
|
const lobby2Response = await request(app).post('/api/lobbies').send({
|
||||||
|
name: 'Test Lobby 2',
|
||||||
|
owner: 'player2'
|
||||||
|
});
|
||||||
|
testLobbyId2 = lobby2Response.body.id;
|
||||||
|
|
||||||
|
// Add player1 to lobby2
|
||||||
|
await request(app).post('/api/lobbies/join').send({
|
||||||
|
inviteCode: lobby2Response.body.inviteCode,
|
||||||
|
username: 'player1'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return lobbies for a specific user', async () => {
|
||||||
|
const response = await request(app).get('/api/lobbies/user/player1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toHaveLength(2); // player1 is in both lobbies
|
||||||
|
expect(response.body.some((lobby: any) => lobby.id === testLobbyId1)).toBe(true);
|
||||||
|
expect(response.body.some((lobby: any) => lobby.id === testLobbyId2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for user with no lobbies', async () => {
|
||||||
|
const response = await request(app).get('/api/lobbies/user/player3');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return only lobbies where user is a player', async () => {
|
||||||
|
const response = await request(app).get('/api/lobbies/user/player2');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toHaveLength(1); // player2 is only in lobby2
|
||||||
|
expect(response.body[0].id).toBe(testLobbyId2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/lobbies/:id/end-turn', () => {
|
||||||
|
let testLobbyId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a lobby with multiple players
|
||||||
|
const lobbyResponse = await request(app).post('/api/lobbies').send({
|
||||||
|
name: 'Test Lobby',
|
||||||
|
owner: 'player1'
|
||||||
|
});
|
||||||
|
testLobbyId = lobbyResponse.body.id;
|
||||||
|
|
||||||
|
// Add more players directly to database
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === testLobbyId);
|
||||||
|
if (lobby) {
|
||||||
|
lobby.players.push({ username: 'player2', left: false });
|
||||||
|
lobby.players.push({ username: 'player3', left: false });
|
||||||
|
await writeDB(db);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should advance turn to next active player', async () => {
|
||||||
|
const response = await request(app).post(`/api/lobbies/${testLobbyId}/end-turn`).send({
|
||||||
|
username: 'player1'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.turn).toBe(1); // Should advance from 0 to 1
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip players who have left', async () => {
|
||||||
|
// First mark player2 as left
|
||||||
|
await request(app).post(`/api/lobbies/${testLobbyId}/leave`).send({ username: 'player2' });
|
||||||
|
|
||||||
|
const response = await request(app).post(`/api/lobbies/${testLobbyId}/end-turn`).send({
|
||||||
|
username: 'player1'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.turn).toBe(2); // Should skip player2 and go to player3
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should wrap around to first player', async () => {
|
||||||
|
// Set turn to last player
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/lobbies/${testLobbyId}/set-turn`)
|
||||||
|
.send({ newTurnIndex: 2, username: 'player1' });
|
||||||
|
|
||||||
|
const response = await request(app).post(`/api/lobbies/${testLobbyId}/end-turn`).send({
|
||||||
|
username: 'player1'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.turn).toBe(0); // Should wrap to first player
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 if no active players', async () => {
|
||||||
|
// Mark all players as left
|
||||||
|
await request(app).post(`/api/lobbies/${testLobbyId}/leave`).send({ username: 'player1' });
|
||||||
|
await request(app).post(`/api/lobbies/${testLobbyId}/leave`).send({ username: 'player2' });
|
||||||
|
await request(app).post(`/api/lobbies/${testLobbyId}/leave`).send({ username: 'player3' });
|
||||||
|
|
||||||
|
const response = await request(app).post(`/api/lobbies/${testLobbyId}/end-turn`).send({
|
||||||
|
username: 'player1'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.text).toBe('No active players in lobby');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/lobbies/:id/reorder', () => {
|
||||||
|
let testLobbyId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const lobbyResponse = await request(app).post('/api/lobbies').send({
|
||||||
|
name: 'Test Lobby',
|
||||||
|
owner: 'player1'
|
||||||
|
});
|
||||||
|
testLobbyId = lobbyResponse.body.id;
|
||||||
|
|
||||||
|
// Add more players
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === testLobbyId);
|
||||||
|
if (lobby) {
|
||||||
|
lobby.players.push({ username: 'player2', left: false });
|
||||||
|
lobby.players.push({ username: 'player3', left: false });
|
||||||
|
await writeDB(db);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reorder players successfully', async () => {
|
||||||
|
const newOrder = [
|
||||||
|
{ username: 'player3', left: false },
|
||||||
|
{ username: 'player1', left: false },
|
||||||
|
{ username: 'player2', left: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/api/lobbies/${testLobbyId}/reorder`)
|
||||||
|
.send({ players: newOrder, username: 'player1' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.players).toEqual(newOrder);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/lobbies/:id/set-turn', () => {
|
||||||
|
let testLobbyId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const lobbyResponse = await request(app).post('/api/lobbies').send({
|
||||||
|
name: 'Test Lobby',
|
||||||
|
owner: 'player1'
|
||||||
|
});
|
||||||
|
testLobbyId = lobbyResponse.body.id;
|
||||||
|
|
||||||
|
// Add more players
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === testLobbyId);
|
||||||
|
if (lobby) {
|
||||||
|
lobby.players.push({ username: 'player2', left: false });
|
||||||
|
lobby.players.push({ username: 'player3', left: false });
|
||||||
|
await writeDB(db);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set turn to valid index', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/api/lobbies/${testLobbyId}/set-turn`)
|
||||||
|
.send({ newTurnIndex: 2, username: 'player1' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.turn).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for invalid turn index', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/api/lobbies/${testLobbyId}/set-turn`)
|
||||||
|
.send({ newTurnIndex: 5, username: 'player1' }); // Out of bounds
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.text).toBe('Invalid turn index');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/lobbies/join', () => {
|
||||||
|
let testInviteCode: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const response = await request(app).post('/api/lobbies').send({
|
||||||
|
name: 'Test Lobby',
|
||||||
|
owner: 'player1'
|
||||||
|
});
|
||||||
|
testInviteCode = response.body.inviteCode;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should join lobby with valid invite code', async () => {
|
||||||
|
const response = await request(app).post('/api/lobbies/join').send({
|
||||||
|
inviteCode: testInviteCode,
|
||||||
|
username: 'newplayer'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.players).toHaveLength(2);
|
||||||
|
expect(response.body.players[1].username).toBe('newplayer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add duplicate player', async () => {
|
||||||
|
// Join twice with same username
|
||||||
|
await request(app).post('/api/lobbies/join').send({
|
||||||
|
inviteCode: testInviteCode,
|
||||||
|
username: 'newplayer'
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app).post('/api/lobbies/join').send({
|
||||||
|
inviteCode: testInviteCode,
|
||||||
|
username: 'newplayer'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.players).toHaveLength(2); // Should still be only 2 players
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for invalid invite code', async () => {
|
||||||
|
const response = await request(app).post('/api/lobbies/join').send({
|
||||||
|
inviteCode: 'invalid-code',
|
||||||
|
username: 'newplayer'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.text).toBe('Lobby not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 if lobby is full', async () => {
|
||||||
|
// Fill lobby to 10 players
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.inviteCode === testInviteCode);
|
||||||
|
if (lobby) {
|
||||||
|
for (let i = 2; i <= 10; i++) {
|
||||||
|
lobby.players.push({ username: `player${i}`, left: false });
|
||||||
|
}
|
||||||
|
await writeDB(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request(app).post('/api/lobbies/join').send({
|
||||||
|
inviteCode: testInviteCode,
|
||||||
|
username: 'newplayer'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.text).toBe('Lobby is full');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/lobbies/:id/leave', () => {
|
||||||
|
let testLobbyId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const response = await request(app).post('/api/lobbies').send({
|
||||||
|
name: 'Test Lobby',
|
||||||
|
owner: 'player1'
|
||||||
|
});
|
||||||
|
testLobbyId = response.body.id;
|
||||||
|
|
||||||
|
// Add another player
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === testLobbyId);
|
||||||
|
if (lobby) {
|
||||||
|
lobby.players.push({ username: 'player2', left: false });
|
||||||
|
await writeDB(db);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark player as left', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/api/lobbies/${testLobbyId}/leave`)
|
||||||
|
.send({ username: 'player2' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.text).toBe('Player left lobby');
|
||||||
|
|
||||||
|
// Verify player is marked as left
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === testLobbyId);
|
||||||
|
expect(lobby?.players[1].left).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent player', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/api/lobbies/${testLobbyId}/leave`)
|
||||||
|
.send({ username: 'nonexistentplayer' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.text).toBe('Player not found in lobby');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/lobbies/:id/remove', () => {
|
||||||
|
let testLobbyId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const response = await request(app).post('/api/lobbies').send({
|
||||||
|
name: 'Test Lobby',
|
||||||
|
owner: 'player1'
|
||||||
|
});
|
||||||
|
testLobbyId = response.body.id;
|
||||||
|
|
||||||
|
// Add more players
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === testLobbyId);
|
||||||
|
if (lobby) {
|
||||||
|
lobby.players.push({ username: 'player2', left: false });
|
||||||
|
lobby.players.push({ username: 'player3', left: false });
|
||||||
|
await writeDB(db);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove player completely', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/api/lobbies/${testLobbyId}/remove`)
|
||||||
|
.send({ username: 'player2', removedBy: 'player1' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.text).toBe('Player removed from lobby');
|
||||||
|
|
||||||
|
// Verify player is completely removed
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === testLobbyId);
|
||||||
|
expect(lobby?.players).toHaveLength(2);
|
||||||
|
expect(lobby?.players.find((p) => p.username === 'player2')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should adjust turn if removed player was current turn', async () => {
|
||||||
|
// Set turn to last player
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === testLobbyId);
|
||||||
|
if (lobby) {
|
||||||
|
lobby.turn = 2; // Last player (player3)
|
||||||
|
await writeDB(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/api/lobbies/${testLobbyId}/remove`)
|
||||||
|
.send({ username: 'player3', removedBy: 'player1' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
// Verify turn was adjusted
|
||||||
|
const updatedDb = await readDB();
|
||||||
|
const updatedLobby = updatedDb.lobbies.find((l) => l.id === testLobbyId);
|
||||||
|
expect(updatedLobby?.turn).toBe(0); // Should reset to 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
182
backend/src/__tests__/api/users.test.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import request from 'supertest';
|
||||||
|
import express from 'express';
|
||||||
|
import bodyParser from 'body-parser';
|
||||||
|
import { usersRouter } from '../../api/users';
|
||||||
|
import { readDB, writeDB } from '../../db/db';
|
||||||
|
import { migrateData } from '../../db/migrate';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
app.use('/api/users', usersRouter);
|
||||||
|
|
||||||
|
describe('Users API', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Set environment variable for tests
|
||||||
|
process.env.SUPER_ADMIN_PASSWORD = 'testadminpass';
|
||||||
|
// Run migration to create super admin user
|
||||||
|
await migrateData();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/users/register', () => {
|
||||||
|
it('should register a new user successfully', async () => {
|
||||||
|
const response = await request(app).post('/api/users/register').send({
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body).toEqual({ username: 'testuser', isSuper: false });
|
||||||
|
|
||||||
|
// Verify user was saved to database
|
||||||
|
const db = await readDB();
|
||||||
|
expect(db.users.filter((u) => u.username !== 'superadmin')).toHaveLength(1);
|
||||||
|
const user = db.users.find((u) => u.username === 'testuser');
|
||||||
|
expect(user?.username).toBe('testuser');
|
||||||
|
expect(user?.password).toBeDefined();
|
||||||
|
expect(user?.createdAt).toBeDefined();
|
||||||
|
expect(user?.lastLoginAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 if user already exists', async () => {
|
||||||
|
// Create user first
|
||||||
|
await request(app).post('/api/users/register').send({
|
||||||
|
username: 'duplicate',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to create same user again
|
||||||
|
const response = await request(app).post('/api/users/register').send({
|
||||||
|
username: 'duplicate',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.text).toBe('User already exists');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent registration of superadmin username', async () => {
|
||||||
|
const response = await request(app).post('/api/users/register').send({
|
||||||
|
username: 'superadmin',
|
||||||
|
password: 'somepassword'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.text).toBe('Username not available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing username or password gracefully', async () => {
|
||||||
|
const response1 = await request(app).post('/api/users/register').send({ username: 'test' }); // missing password
|
||||||
|
|
||||||
|
const response2 = await request(app)
|
||||||
|
.post('/api/users/register')
|
||||||
|
.send({ password: 'test123' }); // missing username
|
||||||
|
|
||||||
|
// The API should handle these gracefully (either by validation or allowing empty values)
|
||||||
|
expect([201, 400, 500].includes(response1.status)).toBe(true);
|
||||||
|
expect([201, 400, 500].includes(response2.status)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/users/login', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a test user
|
||||||
|
await request(app).post('/api/users/register').send({
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should login successfully with correct credentials', async () => {
|
||||||
|
const response = await request(app).post('/api/users/login').send({
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ username: 'testuser', isSuper: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for invalid username', async () => {
|
||||||
|
const response = await request(app).post('/api/users/login').send({
|
||||||
|
username: 'nonexistent',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.text).toBe('Invalid credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for invalid password', async () => {
|
||||||
|
const response = await request(app).post('/api/users/login').send({
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'wrongpassword'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.text).toBe('Invalid credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should login super admin with correct password', async () => {
|
||||||
|
const response = await request(app).post('/api/users/login').send({
|
||||||
|
username: 'superadmin',
|
||||||
|
password: 'testadminpass'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ username: 'superadmin', isSuper: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject super admin with wrong password', async () => {
|
||||||
|
const response = await request(app).post('/api/users/login').send({
|
||||||
|
username: 'superadmin',
|
||||||
|
password: 'wrongpassword'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.text).toBe('Invalid credentials');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/users/reset-password', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a test user
|
||||||
|
await request(app).post('/api/users/register').send({
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset password with valid super admin credentials', async () => {
|
||||||
|
const response = await request(app).post('/api/users/reset-password').send({
|
||||||
|
username: 'testuser',
|
||||||
|
newPassword: 'newpassword123',
|
||||||
|
superAdminPassword: 'testadminpass'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.text).toBe('Password reset successful');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 for invalid super admin password', async () => {
|
||||||
|
const response = await request(app).post('/api/users/reset-password').send({
|
||||||
|
username: 'testuser',
|
||||||
|
newPassword: 'newpassword123',
|
||||||
|
superAdminPassword: 'wrongpassword'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.text).toBe('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent user', async () => {
|
||||||
|
const response = await request(app).post('/api/users/reset-password').send({
|
||||||
|
username: 'nonexistent',
|
||||||
|
newPassword: 'newpassword123',
|
||||||
|
superAdminPassword: 'testadminpass'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.text).toBe('User not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
26
backend/src/__tests__/setup.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Use a test database file
|
||||||
|
const TEST_DB_PATH = path.resolve(__dirname, '../../test-db.json');
|
||||||
|
|
||||||
|
// Create initial test database
|
||||||
|
const createTestDB = async () => {
|
||||||
|
const initialData = { users: [], lobbies: [] };
|
||||||
|
await fs.writeFile(TEST_DB_PATH, JSON.stringify(initialData, null, 2));
|
||||||
|
return initialData;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up test database before each test
|
||||||
|
beforeEach(async () => {
|
||||||
|
await createTestDB();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up test database after all tests
|
||||||
|
afterAll(async () => {
|
||||||
|
try {
|
||||||
|
await fs.unlink(TEST_DB_PATH);
|
||||||
|
} catch (error) {
|
||||||
|
// File doesn't exist, that's ok
|
||||||
|
}
|
||||||
|
});
|
||||||
205
backend/src/api/admin.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { readDB, writeDB } from '../db/db';
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import { sendPushNotification } from '../services/pushNotificationService';
|
||||||
|
|
||||||
|
export const adminRouter = (wss: WebSocketServer) => {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Middleware to check super admin authorization
|
||||||
|
const requireSuperAdmin = async (req: any, res: any, next: any) => {
|
||||||
|
const { username } = req.body;
|
||||||
|
if (!username) {
|
||||||
|
return res.status(401).send('Username required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists and has super admin privileges
|
||||||
|
const db = await readDB();
|
||||||
|
const user = db.users.find((u) => u.username === username);
|
||||||
|
if (!user || !user.isSuper) {
|
||||||
|
return res.status(401).send('Unauthorized - Super admin access required');
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get dashboard data (all users and lobbies)
|
||||||
|
router.post('/dashboard', requireSuperAdmin, async (req, res) => {
|
||||||
|
const db = await readDB();
|
||||||
|
|
||||||
|
// Sort users by last login (most recent first)
|
||||||
|
const { username: currentAdmin } = req.body;
|
||||||
|
const users = db.users
|
||||||
|
.filter((user) => user.username !== currentAdmin) // Don't show the current admin user in the list
|
||||||
|
.map((user) => ({
|
||||||
|
username: user.username,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
lastLoginAt: user.lastLoginAt,
|
||||||
|
isSuper: user.isSuper || false // Include admin status for display
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (!a.lastLoginAt) return 1;
|
||||||
|
if (!b.lastLoginAt) return -1;
|
||||||
|
return new Date(b.lastLoginAt).getTime() - new Date(a.lastLoginAt).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort lobbies by last activity (most recent first)
|
||||||
|
const lobbies = db.lobbies
|
||||||
|
.map((lobby) => ({
|
||||||
|
id: lobby.id,
|
||||||
|
name: lobby.name,
|
||||||
|
owner: lobby.owner,
|
||||||
|
playerCount: lobby.players.length,
|
||||||
|
activePlayerCount: lobby.players.filter((p) => !p.left).length,
|
||||||
|
createdAt: lobby.createdAt,
|
||||||
|
lastActivity: lobby.lastActivity,
|
||||||
|
currentPlayer: lobby.players[lobby.turn]?.username,
|
||||||
|
recentActions: lobby.actions.slice(-5).reverse() // Last 5 actions, most recent first
|
||||||
|
}))
|
||||||
|
.sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime());
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
users,
|
||||||
|
lobbies,
|
||||||
|
totalUsers: users.length,
|
||||||
|
totalLobbies: lobbies.length,
|
||||||
|
activeLobbies: lobbies.filter((l) => l.activePlayerCount > 0).length
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get detailed lobby information
|
||||||
|
router.post('/lobby/:id', requireSuperAdmin, async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === id);
|
||||||
|
|
||||||
|
if (!lobby) {
|
||||||
|
return res.status(404).send('Lobby not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...lobby,
|
||||||
|
actions: lobby.actions.slice().reverse() // All actions, most recent first
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset user password
|
||||||
|
router.post('/reset-password', requireSuperAdmin, async (req, res) => {
|
||||||
|
const { targetUsername, newPassword } = req.body;
|
||||||
|
const db = await readDB();
|
||||||
|
const user = db.users.find((u) => u.username === targetUsername);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).send('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.isSuper) {
|
||||||
|
return res.status(400).send('Cannot reset super admin password');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||||
|
user.password = hashedPassword;
|
||||||
|
await writeDB(db);
|
||||||
|
|
||||||
|
res.json({ message: `Password reset successful for user: ${targetUsername}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send test notification to all connected clients
|
||||||
|
router.post('/test-notification', requireSuperAdmin, async (req, res) => {
|
||||||
|
const { message, title } = req.body;
|
||||||
|
|
||||||
|
const notification = {
|
||||||
|
type: 'test-notification',
|
||||||
|
title: title || 'Test Notification',
|
||||||
|
message: message || 'This is a test notification from the super admin.',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
let clientCount = 0;
|
||||||
|
wss.clients.forEach((client: WebSocket) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(JSON.stringify(notification));
|
||||||
|
clientCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: `Test notification sent to ${clientCount} connected clients`,
|
||||||
|
notification
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send push notification to specific user
|
||||||
|
router.post('/send-push-notification', requireSuperAdmin, async (req, res) => {
|
||||||
|
const { targetUsername, title, message } = req.body;
|
||||||
|
|
||||||
|
if (!targetUsername) {
|
||||||
|
return res.status(400).send('Target username is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await sendPushNotification(
|
||||||
|
targetUsername,
|
||||||
|
title || 'Admin Notification',
|
||||||
|
message || 'You have a message from the admin.',
|
||||||
|
{ type: 'admin-message', tag: 'admin-notification' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Push notification sent successfully to ${targetUsername}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({
|
||||||
|
success: false,
|
||||||
|
message: `User ${targetUsername} is not subscribed to push notifications`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending admin push notification:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to send push notification'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get system statistics
|
||||||
|
router.post('/stats', requireSuperAdmin, async (req, res) => {
|
||||||
|
const db = await readDB();
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const allUsers = db.users;
|
||||||
|
|
||||||
|
const recentLogins = allUsers.filter(
|
||||||
|
(user) => user.lastLoginAt && new Date(user.lastLoginAt) > oneDayAgo
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const weeklyLogins = allUsers.filter(
|
||||||
|
(user) => user.lastLoginAt && new Date(user.lastLoginAt) > oneWeekAgo
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const recentLobbies = db.lobbies.filter(
|
||||||
|
(lobby) => new Date(lobby.createdAt) > oneWeekAgo
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const activeLobbies = db.lobbies.filter(
|
||||||
|
(lobby) => lobby.players.some((p) => !p.left) && new Date(lobby.lastActivity) > oneDayAgo
|
||||||
|
).length;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
totalUsers: allUsers.length,
|
||||||
|
totalLobbies: db.lobbies.length,
|
||||||
|
recentLogins24h: recentLogins,
|
||||||
|
recentLogins7d: weeklyLogins,
|
||||||
|
recentLobbies7d: recentLobbies,
|
||||||
|
activeLobbies24h: activeLobbies
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
393
backend/src/api/lobbies.ts
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { readDB, writeDB } from '../db/db';
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import { Lobby, Player, LobbyAction } from '../types/types';
|
||||||
|
import { sendTurnChangeNotification } from '../services/pushNotificationService';
|
||||||
|
|
||||||
|
export const lobbiesRouter = (wss: WebSocketServer) => {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const broadcast = (lobbyId: string, message: any) => {
|
||||||
|
wss.clients.forEach((client: WebSocket) => {
|
||||||
|
// TODO: only send to clients in the lobby
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const logAction = (lobby: Lobby, username: string, action: string, details?: string) => {
|
||||||
|
const actionLog: LobbyAction = {
|
||||||
|
id: uuidv4(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
username,
|
||||||
|
action,
|
||||||
|
details
|
||||||
|
};
|
||||||
|
lobby.actions.push(actionLog);
|
||||||
|
lobby.lastActivity = new Date().toISOString();
|
||||||
|
// Keep only last 100 actions to prevent unlimited growth
|
||||||
|
if (lobby.actions.length > 100) {
|
||||||
|
lobby.actions = lobby.actions.slice(-100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const { name, description, owner } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!name || !name.trim()) {
|
||||||
|
return res.status(400).send('Lobby name is required');
|
||||||
|
}
|
||||||
|
if (!owner || !owner.trim()) {
|
||||||
|
return res.status(400).send('Owner is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await readDB();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const newLobby: Lobby = {
|
||||||
|
id: uuidv4(),
|
||||||
|
name: name.trim(),
|
||||||
|
description: description?.trim() || undefined,
|
||||||
|
owner: owner.trim(),
|
||||||
|
players: [{ username: owner, left: false }],
|
||||||
|
turn: 0,
|
||||||
|
inviteCode: uuidv4(),
|
||||||
|
createdAt: now,
|
||||||
|
lastActivity: now,
|
||||||
|
actions: [],
|
||||||
|
locked: false
|
||||||
|
};
|
||||||
|
logAction(newLobby, owner, 'Lobby created', `Lobby "${name}" created`);
|
||||||
|
db.lobbies.push(newLobby);
|
||||||
|
await writeDB(db);
|
||||||
|
res.status(201).json(newLobby);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const db = await readDB();
|
||||||
|
res.json(db.lobbies);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === id);
|
||||||
|
if (lobby) {
|
||||||
|
res.json(lobby);
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Lobby not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/user/:username', async (req, res) => {
|
||||||
|
const { username } = req.params;
|
||||||
|
const db = await readDB();
|
||||||
|
|
||||||
|
// Find lobbies where the user is a player
|
||||||
|
const userLobbies = db.lobbies.filter((lobby) =>
|
||||||
|
lobby.players.some((player) => player.username === username)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(userLobbies);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/end-turn', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { username } = req.body;
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === id);
|
||||||
|
if (lobby) {
|
||||||
|
let nextTurn = lobby.turn;
|
||||||
|
let playersInGame = lobby.players.filter((p) => !p.left);
|
||||||
|
if (playersInGame.length === 0) {
|
||||||
|
return res.status(400).send('No active players in lobby');
|
||||||
|
}
|
||||||
|
const currentPlayer = lobby.players[lobby.turn];
|
||||||
|
do {
|
||||||
|
nextTurn = (nextTurn + 1) % lobby.players.length;
|
||||||
|
} while (lobby.players[nextTurn].left);
|
||||||
|
lobby.turn = nextTurn;
|
||||||
|
const nextPlayer = lobby.players[nextTurn];
|
||||||
|
logAction(
|
||||||
|
lobby,
|
||||||
|
username || currentPlayer.username,
|
||||||
|
'Turn ended',
|
||||||
|
`Turn passed from ${currentPlayer.username} to ${nextPlayer.username}`
|
||||||
|
);
|
||||||
|
await writeDB(db);
|
||||||
|
broadcast(id, { type: 'turn-change', lobby });
|
||||||
|
|
||||||
|
// Send push notification to the next player
|
||||||
|
if (nextPlayer && nextPlayer.username) {
|
||||||
|
sendTurnChangeNotification(nextPlayer.username, lobby.name, lobby.id).catch((error) => {
|
||||||
|
console.error(`Failed to send push notification to ${nextPlayer.username}:`, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(lobby);
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Lobby not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/reorder', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { players, username } = req.body;
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === id);
|
||||||
|
if (lobby) {
|
||||||
|
if (lobby.locked) {
|
||||||
|
return res.status(403).send('Turn order is locked and cannot be changed');
|
||||||
|
}
|
||||||
|
lobby.players = players;
|
||||||
|
logAction(lobby, username, 'Player order changed', 'Turn order was reordered');
|
||||||
|
await writeDB(db);
|
||||||
|
broadcast(id, { type: 'player-reorder', lobby });
|
||||||
|
res.json(lobby);
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Lobby not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/set-turn', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { newTurnIndex, username } = req.body;
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === id);
|
||||||
|
if (lobby) {
|
||||||
|
if (newTurnIndex >= 0 && newTurnIndex < lobby.players.length) {
|
||||||
|
const newCurrentPlayer = lobby.players[newTurnIndex];
|
||||||
|
lobby.turn = newTurnIndex;
|
||||||
|
logAction(lobby, username, 'Turn manually set', `Turn set to ${newCurrentPlayer.username}`);
|
||||||
|
await writeDB(db);
|
||||||
|
broadcast(id, { type: 'turn-change', lobby });
|
||||||
|
res.json(lobby);
|
||||||
|
} else {
|
||||||
|
res.status(400).send('Invalid turn index');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Lobby not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/join', async (req, res) => {
|
||||||
|
const { inviteCode, username, password } = req.body;
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.inviteCode === inviteCode);
|
||||||
|
if (lobby) {
|
||||||
|
if (lobby.password && !(await bcrypt.compare(password, lobby.password))) {
|
||||||
|
return res.status(401).send('Incorrect lobby password');
|
||||||
|
}
|
||||||
|
if (lobby.players.length >= 10) {
|
||||||
|
return res.status(400).send('Lobby is full');
|
||||||
|
}
|
||||||
|
if (!lobby.players.find((p: Player) => p.username === username)) {
|
||||||
|
lobby.players.push({ username, left: false });
|
||||||
|
logAction(lobby, username, 'Player joined', `${username} joined the lobby`);
|
||||||
|
await writeDB(db);
|
||||||
|
broadcast(lobby.id, { type: 'player-join', lobby });
|
||||||
|
}
|
||||||
|
res.json(lobby);
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Lobby not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/rename', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { newName, username } = req.body;
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === id);
|
||||||
|
if (lobby) {
|
||||||
|
const oldName = lobby.name;
|
||||||
|
lobby.name = newName;
|
||||||
|
logAction(
|
||||||
|
lobby,
|
||||||
|
username,
|
||||||
|
'Lobby renamed',
|
||||||
|
`Lobby renamed from "${oldName}" to "${newName}"`
|
||||||
|
);
|
||||||
|
await writeDB(db);
|
||||||
|
broadcast(id, { type: 'lobby-update', lobby });
|
||||||
|
res.json(lobby);
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Lobby not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/set-password', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { password, username } = req.body;
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === id);
|
||||||
|
if (lobby) {
|
||||||
|
lobby.password = password ? await bcrypt.hash(password, 10) : undefined;
|
||||||
|
logAction(
|
||||||
|
lobby,
|
||||||
|
username,
|
||||||
|
'Password changed',
|
||||||
|
password ? 'Lobby password set' : 'Lobby password removed'
|
||||||
|
);
|
||||||
|
await writeDB(db);
|
||||||
|
broadcast(id, { type: 'lobby-update', lobby });
|
||||||
|
res.json(lobby);
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Lobby not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/set-description', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { description, username } = req.body;
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === id);
|
||||||
|
if (lobby) {
|
||||||
|
const oldDescription = lobby.description || 'No description';
|
||||||
|
lobby.description = description?.trim() || undefined;
|
||||||
|
logAction(
|
||||||
|
lobby,
|
||||||
|
username,
|
||||||
|
'Description changed',
|
||||||
|
description?.trim()
|
||||||
|
? `Description changed to "${description.trim()}"`
|
||||||
|
: 'Description removed'
|
||||||
|
);
|
||||||
|
await writeDB(db);
|
||||||
|
broadcast(id, { type: 'lobby-update', lobby });
|
||||||
|
res.json(lobby);
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Lobby not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/set-current-turn', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { targetUsername, username } = req.body;
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === id);
|
||||||
|
if (lobby) {
|
||||||
|
const targetPlayerIndex = lobby.players.findIndex(
|
||||||
|
(p) => p.username === targetUsername && !p.left
|
||||||
|
);
|
||||||
|
if (targetPlayerIndex === -1) {
|
||||||
|
return res.status(400).send('Target player not found or has left the lobby');
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousPlayer = lobby.players[lobby.turn];
|
||||||
|
lobby.turn = targetPlayerIndex;
|
||||||
|
logAction(
|
||||||
|
lobby,
|
||||||
|
username,
|
||||||
|
'Turn manually set',
|
||||||
|
`Turn manually set from ${previousPlayer?.username || 'unknown'} to ${targetUsername}`
|
||||||
|
);
|
||||||
|
await writeDB(db);
|
||||||
|
broadcast(id, { type: 'turn-change', lobby });
|
||||||
|
|
||||||
|
// Send push notification to the target player
|
||||||
|
if (targetUsername) {
|
||||||
|
sendTurnChangeNotification(targetUsername, lobby.name, lobby.id).catch((error) => {
|
||||||
|
console.error(`Failed to send push notification to ${targetUsername}:`, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(lobby);
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Lobby not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/leave', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { username } = req.body;
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === id);
|
||||||
|
if (lobby) {
|
||||||
|
const player = lobby.players.find((p) => p.username === username);
|
||||||
|
if (player) {
|
||||||
|
player.left = true;
|
||||||
|
logAction(lobby, username, 'Player left', `${username} left the lobby`);
|
||||||
|
await writeDB(db);
|
||||||
|
broadcast(id, { type: 'player-leave', lobby });
|
||||||
|
res.send('Player left lobby');
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Player not found in lobby');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Lobby not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/remove', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { username, removedBy } = req.body;
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === id);
|
||||||
|
if (lobby) {
|
||||||
|
lobby.players = lobby.players.filter((p) => p.username !== username);
|
||||||
|
// Adjust turn if the removed player was the current turn
|
||||||
|
if (lobby.turn >= lobby.players.length && lobby.players.length > 0) {
|
||||||
|
lobby.turn = 0;
|
||||||
|
}
|
||||||
|
logAction(lobby, removedBy, 'Player removed', `${username} was removed from the lobby`);
|
||||||
|
await writeDB(db);
|
||||||
|
broadcast(id, { type: 'player-remove', lobby });
|
||||||
|
res.send('Player removed from lobby');
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Lobby not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/lock', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { username } = req.body;
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === id);
|
||||||
|
if (lobby) {
|
||||||
|
if (lobby.owner !== username) {
|
||||||
|
return res.status(403).send('Only the lobby owner can lock/unlock the turn order');
|
||||||
|
}
|
||||||
|
lobby.locked = true;
|
||||||
|
logAction(
|
||||||
|
lobby,
|
||||||
|
username,
|
||||||
|
'Turn order locked',
|
||||||
|
'Turn order has been locked to prevent changes'
|
||||||
|
);
|
||||||
|
await writeDB(db);
|
||||||
|
broadcast(id, { type: 'lobby-update', lobby });
|
||||||
|
res.json(lobby);
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Lobby not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/unlock', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { username } = req.body;
|
||||||
|
const db = await readDB();
|
||||||
|
const lobby = db.lobbies.find((l) => l.id === id);
|
||||||
|
if (lobby) {
|
||||||
|
if (lobby.owner !== username) {
|
||||||
|
return res.status(403).send('Only the lobby owner can lock/unlock the turn order');
|
||||||
|
}
|
||||||
|
lobby.locked = false;
|
||||||
|
logAction(
|
||||||
|
lobby,
|
||||||
|
username,
|
||||||
|
'Turn order unlocked',
|
||||||
|
'Turn order has been unlocked and can be changed'
|
||||||
|
);
|
||||||
|
await writeDB(db);
|
||||||
|
broadcast(id, { type: 'lobby-update', lobby });
|
||||||
|
res.json(lobby);
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Lobby not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
134
backend/src/api/push.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import {
|
||||||
|
addPushSubscription,
|
||||||
|
removePushSubscription,
|
||||||
|
getVapidPublicKey,
|
||||||
|
sendPushNotification,
|
||||||
|
getPushSubscriptions
|
||||||
|
} from '../services/pushNotificationService';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Get VAPID public key for client-side subscription
|
||||||
|
router.get('/vapid-public-key', (req, res) => {
|
||||||
|
try {
|
||||||
|
const publicKey = getVapidPublicKey();
|
||||||
|
res.json({ publicKey });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting VAPID public key:', error);
|
||||||
|
res.status(500).send('Failed to get VAPID public key');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to push notifications
|
||||||
|
router.post('/subscribe', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, subscription } = req.body;
|
||||||
|
|
||||||
|
if (!username || !subscription) {
|
||||||
|
return res.status(400).send('Username and subscription are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate subscription object
|
||||||
|
if (!subscription.endpoint || !subscription.keys) {
|
||||||
|
return res.status(400).send('Invalid subscription format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionId = await addPushSubscription(username, subscription);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
subscriptionId,
|
||||||
|
message: 'Push subscription added successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error subscribing to push notifications:', error);
|
||||||
|
res.status(500).send('Failed to subscribe to push notifications');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unsubscribe from push notifications
|
||||||
|
router.post('/unsubscribe', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username } = req.body;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
return res.status(400).send('Username is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = await removePushSubscription(username);
|
||||||
|
|
||||||
|
if (removed) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Push subscription removed successfully'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Push subscription not found');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error unsubscribing from push notifications:', error);
|
||||||
|
res.status(500).send('Failed to unsubscribe from push notifications');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test push notification (for debugging)
|
||||||
|
router.post('/test', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, title, body } = req.body;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
return res.status(400).send('Username is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPushNotification(
|
||||||
|
username,
|
||||||
|
title || 'Test Notification',
|
||||||
|
body || 'This is a test push notification',
|
||||||
|
{ type: 'test' }
|
||||||
|
)
|
||||||
|
.then((sent) => {
|
||||||
|
if (sent) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Test notification sent successfully'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(404).send('User not subscribed to push notifications');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error sending test notification:', error);
|
||||||
|
res.status(500).send('Failed to send test notification');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in test notification endpoint:', error);
|
||||||
|
res.status(500).send('Failed to process test notification request');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get subscription status (for debugging)
|
||||||
|
router.get('/status/:username', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username } = req.params;
|
||||||
|
const subscriptions = await getPushSubscriptions();
|
||||||
|
const userSubscription = subscriptions.find((sub) => sub.username === username);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
subscribed: !!userSubscription,
|
||||||
|
subscription: userSubscription
|
||||||
|
? {
|
||||||
|
id: userSubscription.id,
|
||||||
|
createdAt: userSubscription.createdAt,
|
||||||
|
lastUsed: userSubscription.lastUsed
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
totalSubscriptions: subscriptions.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting subscription status:', error);
|
||||||
|
res.status(500).send('Failed to get subscription status');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { router as pushRouter };
|
||||||
70
backend/src/api/users.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { readDB, writeDB } from '../db/db';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const saltRounds = 10;
|
||||||
|
|
||||||
|
router.post('/register', async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
// Prevent registration of superadmin username
|
||||||
|
if (username === 'superadmin') {
|
||||||
|
return res.status(400).send('Username not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await readDB();
|
||||||
|
const existingUser = db.users.find((u) => u.username === username);
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(400).send('User already exists');
|
||||||
|
}
|
||||||
|
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||||
|
const newUser = {
|
||||||
|
username,
|
||||||
|
password: hashedPassword,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastLoginAt: new Date().toISOString(),
|
||||||
|
isSuper: false
|
||||||
|
};
|
||||||
|
db.users.push(newUser);
|
||||||
|
await writeDB(db);
|
||||||
|
res.status(201).json({ username, isSuper: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
const db = await readDB();
|
||||||
|
const user = db.users.find((u) => u.username === username);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(400).send('Invalid credentials');
|
||||||
|
}
|
||||||
|
const match = await bcrypt.compare(password, user.password);
|
||||||
|
if (match) {
|
||||||
|
// Update last login time
|
||||||
|
user.lastLoginAt = new Date().toISOString();
|
||||||
|
await writeDB(db);
|
||||||
|
res.json({ username: user.username, isSuper: user.isSuper || false });
|
||||||
|
} else {
|
||||||
|
res.status(400).send('Invalid credentials');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/reset-password', async (req, res) => {
|
||||||
|
const { username, newPassword, superAdminPassword } = req.body;
|
||||||
|
if (superAdminPassword !== process.env.SUPER_ADMIN_PASSWORD) {
|
||||||
|
return res.status(401).send('Unauthorized');
|
||||||
|
}
|
||||||
|
const db = await readDB();
|
||||||
|
const user = db.users.find((u) => u.username === username);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).send('User not found');
|
||||||
|
}
|
||||||
|
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||||
|
user.password = hashedPassword;
|
||||||
|
await writeDB(db);
|
||||||
|
res.send('Password reset successful');
|
||||||
|
});
|
||||||
|
|
||||||
|
export { router as usersRouter };
|
||||||
37
backend/src/db/db.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { Lobby, User } from '../types/types';
|
||||||
|
|
||||||
|
export interface PushSubscription {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
subscription: any; // webpush.PushSubscription serialized
|
||||||
|
createdAt: string;
|
||||||
|
lastUsed?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use environment variable for data directory, fallback to relative path
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../..');
|
||||||
|
const DB_PATH =
|
||||||
|
process.env.NODE_ENV === 'test'
|
||||||
|
? path.join(DATA_DIR, 'test-db.json')
|
||||||
|
: path.join(DATA_DIR, 'db.json');
|
||||||
|
|
||||||
|
export interface DB {
|
||||||
|
users: User[];
|
||||||
|
lobbies: Lobby[];
|
||||||
|
pushSubscriptions: PushSubscription[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readDB = async (): Promise<DB> => {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(DB_PATH, 'utf-8');
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (error) {
|
||||||
|
return { users: [], lobbies: [], pushSubscriptions: [] };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeDB = async (data: DB): Promise<void> => {
|
||||||
|
await fs.writeFile(DB_PATH, JSON.stringify(data, null, 2));
|
||||||
|
};
|
||||||
81
backend/src/db/migrate.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { readDB, writeDB } from './db';
|
||||||
|
|
||||||
|
export const migrateData = async () => {
|
||||||
|
console.log('Starting data migration...');
|
||||||
|
const db = await readDB();
|
||||||
|
let migrationNeeded = false;
|
||||||
|
|
||||||
|
// Migrate users
|
||||||
|
db.users.forEach((user) => {
|
||||||
|
if (!user.createdAt) {
|
||||||
|
user.createdAt = new Date().toISOString();
|
||||||
|
migrationNeeded = true;
|
||||||
|
}
|
||||||
|
if (!user.lastLoginAt) {
|
||||||
|
user.lastLoginAt = user.createdAt;
|
||||||
|
migrationNeeded = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create super admin user if it doesn't exist
|
||||||
|
const superAdminPassword = process.env.SUPER_ADMIN_PASSWORD;
|
||||||
|
if (superAdminPassword) {
|
||||||
|
const existingSuperAdmin = db.users.find((user) => user.username === 'superadmin');
|
||||||
|
if (!existingSuperAdmin) {
|
||||||
|
console.log('Creating super admin user...');
|
||||||
|
const hashedPassword = await bcrypt.hash(superAdminPassword, 10);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.users.push({
|
||||||
|
username: 'superadmin',
|
||||||
|
password: hashedPassword,
|
||||||
|
createdAt: now,
|
||||||
|
lastLoginAt: now,
|
||||||
|
isSuper: true
|
||||||
|
});
|
||||||
|
migrationNeeded = true;
|
||||||
|
console.log('Super admin user created successfully');
|
||||||
|
} else if (!existingSuperAdmin.isSuper) {
|
||||||
|
// Ensure existing superadmin user has the isSuper flag
|
||||||
|
existingSuperAdmin.isSuper = true;
|
||||||
|
migrationNeeded = true;
|
||||||
|
console.log('Updated existing superadmin user with super privileges');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('SUPER_ADMIN_PASSWORD not set - super admin user will not be created');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate lobbies
|
||||||
|
db.lobbies.forEach((lobby) => {
|
||||||
|
if (!lobby.createdAt) {
|
||||||
|
lobby.createdAt = new Date().toISOString();
|
||||||
|
migrationNeeded = true;
|
||||||
|
}
|
||||||
|
if (!lobby.lastActivity) {
|
||||||
|
lobby.lastActivity = lobby.createdAt;
|
||||||
|
migrationNeeded = true;
|
||||||
|
}
|
||||||
|
if (!lobby.actions) {
|
||||||
|
lobby.actions = [];
|
||||||
|
migrationNeeded = true;
|
||||||
|
}
|
||||||
|
if (lobby.locked === undefined) {
|
||||||
|
lobby.locked = false;
|
||||||
|
migrationNeeded = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migrate push subscriptions - add array if it doesn't exist
|
||||||
|
if (!db.pushSubscriptions) {
|
||||||
|
db.pushSubscriptions = [];
|
||||||
|
migrationNeeded = true;
|
||||||
|
console.log('Added pushSubscriptions array to database');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrationNeeded) {
|
||||||
|
await writeDB(db);
|
||||||
|
console.log('Data migration completed successfully');
|
||||||
|
} else {
|
||||||
|
console.log('No migration needed - data is up to date');
|
||||||
|
}
|
||||||
|
};
|
||||||
61
backend/src/index.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import bodyParser from 'body-parser';
|
||||||
|
import http from 'http';
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import { lobbiesRouter } from './api/lobbies';
|
||||||
|
import { usersRouter } from './api/users';
|
||||||
|
import { adminRouter } from './api/admin';
|
||||||
|
import { pushRouter } from './api/push';
|
||||||
|
import { migrateData } from './db/migrate';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
|
// Serve static files from the frontend build directory
|
||||||
|
// In Docker: /usr/src/app/frontend/build, in local dev: ../frontend/build
|
||||||
|
const frontendBuildPath =
|
||||||
|
process.env.NODE_ENV === 'production' ? '/usr/src/app/frontend/build' : '../frontend/build';
|
||||||
|
app.use(express.static(frontendBuildPath));
|
||||||
|
|
||||||
|
app.use('/api/lobbies', lobbiesRouter(wss));
|
||||||
|
app.use('/api/users', usersRouter);
|
||||||
|
app.use('/api/admin', adminRouter(wss));
|
||||||
|
app.use('/api/push', pushRouter);
|
||||||
|
|
||||||
|
// Catch-all handler for React Router - serve index.html for all non-API routes
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
// Skip API routes
|
||||||
|
if (req.path.startsWith('/api/')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
// For all other routes, serve the React app
|
||||||
|
const indexPath =
|
||||||
|
process.env.NODE_ENV === 'production'
|
||||||
|
? '/usr/src/app/frontend/build/index.html'
|
||||||
|
: require('path').resolve(__dirname, '../../frontend/build/index.html');
|
||||||
|
res.sendFile(indexPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on('connection', (ws: WebSocket) => {
|
||||||
|
console.log('Client connected');
|
||||||
|
ws.on('close', () => console.log('Client disconnected'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Run data migration and start server
|
||||||
|
migrateData()
|
||||||
|
.then(() => {
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Server is running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
206
backend/src/services/pushNotificationService.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import webpush from 'web-push';
|
||||||
|
import { readDB, writeDB, PushSubscription as DBPushSubscription } from '../db/db';
|
||||||
|
|
||||||
|
// VAPID keys for push notifications
|
||||||
|
// In production, these should be stored in environment variables
|
||||||
|
const VAPID_PUBLIC_KEY =
|
||||||
|
process.env.VAPID_PUBLIC_KEY ||
|
||||||
|
'BI3UorjdWfofuMa9Rr7XEpP-6tZ0KKWu2q3koXQotgTT1-EwvBbCGzQwBgjKAmv3ldW21VnrKDHBqGM-SmoaL-E';
|
||||||
|
const VAPID_PRIVATE_KEY =
|
||||||
|
process.env.VAPID_PRIVATE_KEY || '9lGYCKClj4XNKIp4pXs9BfdCKqx7lX9HPwNV5mkFiG8';
|
||||||
|
const VAPID_EMAIL = process.env.VAPID_EMAIL || 'admin@turntracker.app';
|
||||||
|
|
||||||
|
// Initialize web-push with VAPID keys
|
||||||
|
webpush.setVapidDetails(`mailto:${VAPID_EMAIL}`, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
|
||||||
|
|
||||||
|
// Push subscriptions are now stored in the database
|
||||||
|
// Using DBPushSubscription from db.ts
|
||||||
|
|
||||||
|
export const getPushSubscriptions = async (): Promise<DBPushSubscription[]> => {
|
||||||
|
const db = await readDB();
|
||||||
|
return db.pushSubscriptions || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addPushSubscription = async (
|
||||||
|
username: string,
|
||||||
|
subscription: webpush.PushSubscription
|
||||||
|
): Promise<string> => {
|
||||||
|
const id = `${username}-${Date.now()}`;
|
||||||
|
const db = await readDB();
|
||||||
|
|
||||||
|
// Initialize pushSubscriptions array if it doesn't exist
|
||||||
|
if (!db.pushSubscriptions) {
|
||||||
|
db.pushSubscriptions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing subscriptions for this user
|
||||||
|
db.pushSubscriptions = db.pushSubscriptions.filter((sub) => sub.username !== username);
|
||||||
|
|
||||||
|
// Add new subscription
|
||||||
|
const newSubscription: DBPushSubscription = {
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
subscription: subscription,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastUsed: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
db.pushSubscriptions.push(newSubscription);
|
||||||
|
await writeDB(db);
|
||||||
|
console.log(`Added push subscription for user: ${username}`);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removePushSubscription = async (username: string): Promise<boolean> => {
|
||||||
|
const db = await readDB();
|
||||||
|
|
||||||
|
// Initialize pushSubscriptions array if it doesn't exist
|
||||||
|
if (!db.pushSubscriptions) {
|
||||||
|
db.pushSubscriptions = [];
|
||||||
|
await writeDB(db);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialLength = db.pushSubscriptions.length;
|
||||||
|
db.pushSubscriptions = db.pushSubscriptions.filter((sub) => sub.username !== username);
|
||||||
|
const removed = initialLength > db.pushSubscriptions.length;
|
||||||
|
|
||||||
|
if (removed) {
|
||||||
|
await writeDB(db);
|
||||||
|
console.log(`Removed push subscription for user: ${username}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendPushNotification = async (
|
||||||
|
username: string,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
data?: any
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const db = await readDB();
|
||||||
|
|
||||||
|
// Initialize pushSubscriptions array if it doesn't exist
|
||||||
|
if (!db.pushSubscriptions) {
|
||||||
|
db.pushSubscriptions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = db.pushSubscriptions.find((sub) => sub.username === username);
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
console.log(`No push subscription found for user: ${username}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
type: data?.type || 'notification',
|
||||||
|
tag: data?.tag || 'turn-tracker',
|
||||||
|
lobbyId: data?.lobbyId,
|
||||||
|
url: data?.url,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...data
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Sending push notification to ${username}: ${title}`);
|
||||||
|
await webpush.sendNotification(subscription.subscription, payload);
|
||||||
|
|
||||||
|
// Update last used timestamp in database
|
||||||
|
subscription.lastUsed = new Date().toISOString();
|
||||||
|
await writeDB(db);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Failed to send push notification to ${username}:`, error);
|
||||||
|
|
||||||
|
// If subscription is invalid (410 Gone), remove it
|
||||||
|
if (error.statusCode === 410) {
|
||||||
|
console.log(`Push subscription expired for ${username}, removing...`);
|
||||||
|
await removePushSubscription(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendPushNotificationToMultiple = async (
|
||||||
|
usernames: string[],
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
data?: any
|
||||||
|
): Promise<{ success: string[]; failed: string[] }> => {
|
||||||
|
const results = { success: [] as string[], failed: [] as string[] };
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
usernames.map(async (username) => {
|
||||||
|
const sent = await sendPushNotification(username, title, body, data);
|
||||||
|
if (sent) {
|
||||||
|
results.success.push(username);
|
||||||
|
} else {
|
||||||
|
results.failed.push(username);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendTurnChangeNotification = async (
|
||||||
|
username: string,
|
||||||
|
lobbyName: string,
|
||||||
|
lobbyId: string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
return sendPushNotification(username, 'Your Turn!', `It's your turn in ${lobbyName}`, {
|
||||||
|
type: 'turn-change',
|
||||||
|
tag: 'turn-change',
|
||||||
|
lobbyId: lobbyId,
|
||||||
|
url: `/lobby/${lobbyId}`,
|
||||||
|
requireInteraction: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getVapidPublicKey = (): string => {
|
||||||
|
return VAPID_PUBLIC_KEY;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate new VAPID keys (for initial setup)
|
||||||
|
export const generateVapidKeys = () => {
|
||||||
|
const keys = webpush.generateVAPIDKeys();
|
||||||
|
console.log('Generated VAPID keys:');
|
||||||
|
console.log('Public Key:', keys.publicKey);
|
||||||
|
console.log('Private Key:', keys.privateKey);
|
||||||
|
console.log('Set these as environment variables:');
|
||||||
|
console.log(`VAPID_PUBLIC_KEY=${keys.publicKey}`);
|
||||||
|
console.log(`VAPID_PRIVATE_KEY=${keys.privateKey}`);
|
||||||
|
return keys;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup old subscriptions (run periodically)
|
||||||
|
export const cleanupOldSubscriptions = async () => {
|
||||||
|
const db = await readDB();
|
||||||
|
|
||||||
|
// Initialize pushSubscriptions array if it doesn't exist
|
||||||
|
if (!db.pushSubscriptions) {
|
||||||
|
db.pushSubscriptions = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oneMonthAgo = new Date();
|
||||||
|
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||||
|
|
||||||
|
const initialLength = db.pushSubscriptions.length;
|
||||||
|
db.pushSubscriptions = db.pushSubscriptions.filter((sub) => {
|
||||||
|
const lastUsed = new Date(sub.lastUsed || sub.createdAt);
|
||||||
|
return lastUsed > oneMonthAgo;
|
||||||
|
});
|
||||||
|
|
||||||
|
const removed = initialLength - db.pushSubscriptions.length;
|
||||||
|
if (removed > 0) {
|
||||||
|
await writeDB(db);
|
||||||
|
console.log(`Cleaned up ${removed} old push subscriptions`);
|
||||||
|
}
|
||||||
|
};
|
||||||
35
backend/src/types/types.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export interface Player {
|
||||||
|
username: string;
|
||||||
|
left?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LobbyAction {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
username: string;
|
||||||
|
action: string;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Lobby {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
owner: string;
|
||||||
|
players: Player[];
|
||||||
|
turn: number;
|
||||||
|
inviteCode: string;
|
||||||
|
password?: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastActivity: string;
|
||||||
|
actions: LobbyAction[];
|
||||||
|
locked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastLoginAt?: string;
|
||||||
|
isSuper?: boolean;
|
||||||
|
}
|
||||||
12
backend/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es6",
|
||||||
|
"module": "commonjs",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
}
|
||||||
|
}
|
||||||
14768
cursor_log.md
Normal file
11
docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
turn-tracker:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- '3001:3001'
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- SUPER_ADMIN_PASSWORD=l2jkb13as5kldjba6lj4sdf6weh6oo2
|
||||||
|
restart: unless-stopped
|
||||||
23
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
46
frontend/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Getting Started with Create React App
|
||||||
|
|
||||||
|
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `npm start`
|
||||||
|
|
||||||
|
Runs the app in the development mode.\
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||||
|
|
||||||
|
The page will reload if you make edits.\
|
||||||
|
You will also see any lint errors in the console.
|
||||||
|
|
||||||
|
### `npm test`
|
||||||
|
|
||||||
|
Launches the test runner in the interactive watch mode.\
|
||||||
|
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||||
|
|
||||||
|
### `npm run build`
|
||||||
|
|
||||||
|
Builds the app for production to the `build` folder.\
|
||||||
|
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
The build is minified and the filenames include the hashes.\
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
|
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||||
|
|
||||||
|
### `npm run eject`
|
||||||
|
|
||||||
|
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||||
|
|
||||||
|
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||||
|
|
||||||
|
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||||
|
|
||||||
|
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||||
|
|
||||||
|
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||||
18387
frontend/package-lock.json
generated
Normal file
56
frontend/package.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@mui/icons-material": "^7.2.0",
|
||||||
|
"@mui/material": "^7.2.0",
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@types/jest": "^27.5.2",
|
||||||
|
"@types/node": "^16.18.126",
|
||||||
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"axios": "^1.10.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.7.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"web-vitals": "^2.1.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react-router-dom": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
frontend/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 699 B |
BIN
frontend/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
43
frontend/public/index.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using create-react-app"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>React App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
frontend/public/logo192.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
frontend/public/logo512.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
25
frontend/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
3
frontend/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
134
frontend/public/service-worker.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
// Turn Tracker Service Worker - Handles background notifications
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
console.log('Service Worker: Installing...');
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('Service Worker: Activated');
|
||||||
|
clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
console.log('Push notification received:', event);
|
||||||
|
|
||||||
|
if (!event.data) {
|
||||||
|
console.log('Push event has no data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = event.data.json();
|
||||||
|
console.log('Push data:', data);
|
||||||
|
|
||||||
|
const notificationOptions = {
|
||||||
|
body: data.body || data.message || 'Turn Tracker notification',
|
||||||
|
icon: '/logo192.png',
|
||||||
|
badge: '/logo192.png',
|
||||||
|
tag: data.tag || 'turn-tracker',
|
||||||
|
requireInteraction: true, // Keep notification visible until user interacts
|
||||||
|
data: {
|
||||||
|
url: data.url || '/',
|
||||||
|
lobbyId: data.lobbyId,
|
||||||
|
type: data.type
|
||||||
|
},
|
||||||
|
actions: data.actions || [
|
||||||
|
{
|
||||||
|
action: 'open',
|
||||||
|
title: 'Open Game',
|
||||||
|
icon: '/logo192.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'dismiss',
|
||||||
|
title: 'Dismiss'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add vibration for mobile devices
|
||||||
|
if (data.type === 'turn-change') {
|
||||||
|
notificationOptions.vibrate = [200, 100, 200];
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = data.title || 'Turn Tracker';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(title, notificationOptions)
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing push notification:', error);
|
||||||
|
// Fallback notification
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification('Turn Tracker', {
|
||||||
|
body: 'You have a new notification',
|
||||||
|
icon: '/logo192.png',
|
||||||
|
tag: 'turn-tracker-fallback'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
console.log('Notification clicked:', event);
|
||||||
|
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
const data = event.notification.data || {};
|
||||||
|
const action = event.action;
|
||||||
|
|
||||||
|
if (action === 'dismiss') {
|
||||||
|
return; // Just close the notification
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which URL to open
|
||||||
|
let urlToOpen = '/';
|
||||||
|
if (data.lobbyId) {
|
||||||
|
urlToOpen = `/lobby/${data.lobbyId}`;
|
||||||
|
} else if (data.url) {
|
||||||
|
urlToOpen = data.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to focus existing tab or open new one
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
|
console.log('Found clients:', clientList.length);
|
||||||
|
|
||||||
|
// Check if app is already open
|
||||||
|
for (let client of clientList) {
|
||||||
|
if (client.url.includes(self.location.origin)) {
|
||||||
|
console.log('Focusing existing tab:', client.url);
|
||||||
|
client.focus();
|
||||||
|
if (urlToOpen !== '/') {
|
||||||
|
client.navigate(urlToOpen);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No existing tab found, open new one
|
||||||
|
console.log('Opening new tab:', urlToOpen);
|
||||||
|
return clients.openWindow(urlToOpen);
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Error handling notification click:', error);
|
||||||
|
// Fallback: just open the root
|
||||||
|
return clients.openWindow('/');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle background sync for offline support
|
||||||
|
self.addEventListener('sync', (event) => {
|
||||||
|
console.log('Background sync triggered:', event.tag);
|
||||||
|
if (event.tag === 'turn-update') {
|
||||||
|
event.waitUntil(syncTurnUpdates());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Placeholder for future offline sync functionality
|
||||||
|
function syncTurnUpdates() {
|
||||||
|
console.log('Syncing turn updates...');
|
||||||
|
// Future: sync any missed turn updates when back online
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
1
frontend/public/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
38
frontend/src/App.css
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
.App {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-logo {
|
||||||
|
height: 40vmin;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.App-logo {
|
||||||
|
animation: App-logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-header {
|
||||||
|
background-color: #282c34;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: calc(10px + 2vmin);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-link {
|
||||||
|
color: #61dafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes App-logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
frontend/src/App.test.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
test('renders learn react link', () => {
|
||||||
|
render(<App />);
|
||||||
|
const linkElement = screen.getByText(/learn react/i);
|
||||||
|
expect(linkElement).toBeInTheDocument();
|
||||||
|
});
|
||||||
100
frontend/src/App.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
|
||||||
|
import { ThemeProvider, createTheme } from '@mui/material/styles'
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline'
|
||||||
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
import HomePage from './components/pages/HomePage'
|
||||||
|
import LobbyPage from './components/pages/LobbyPage'
|
||||||
|
import JoinLobbyPage from './components/pages/JoinLobbyPage'
|
||||||
|
import AdminDashboard from './components/pages/AdminDashboard'
|
||||||
|
import NotFoundPage from './components/pages/NotFoundPage'
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'light',
|
||||||
|
primary: {
|
||||||
|
main: '#1976d2',
|
||||||
|
light: '#42a5f5',
|
||||||
|
dark: '#1565c0',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: '#dc004e',
|
||||||
|
light: '#ff5983',
|
||||||
|
dark: '#9a0036',
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#f5f5f5',
|
||||||
|
paper: '#ffffff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||||
|
h1: {
|
||||||
|
fontSize: '2.5rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#1976d2',
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontSize: '2rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
textTransform: 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
boxShadow: '0 2px 12px rgba(0,0,0,0.08)',
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTextField: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<AuthProvider>
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/lobby/:id" element={<LobbyPage />} />
|
||||||
|
<Route path="/join/:inviteCode" element={<JoinLobbyPage />} />
|
||||||
|
<Route path="/admin" element={<AdminDashboard />} />
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
93
frontend/src/__tests__/components/HomePage.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import axios from 'axios'
|
||||||
|
import HomePage from '../../components/pages/HomePage'
|
||||||
|
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>
|
||||||
|
|
||||||
|
// Wrapper for router context
|
||||||
|
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<BrowserRouter>{children}</BrowserRouter>
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('HomePage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the main heading', () => {
|
||||||
|
render(<HomePage />, { wrapper: Wrapper })
|
||||||
|
expect(screen.getByText('Turn Tracker')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders login and register components', () => {
|
||||||
|
render(<HomePage />, { wrapper: Wrapper })
|
||||||
|
expect(screen.getByText('Login')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Register')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders lobby creation section', () => {
|
||||||
|
render(<HomePage />, { wrapper: Wrapper })
|
||||||
|
expect(screen.getByText('Create a new Lobby')).toBeInTheDocument()
|
||||||
|
expect(screen.getByPlaceholderText('Lobby Name')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Create Lobby')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates lobby name input', () => {
|
||||||
|
render(<HomePage />, { wrapper: Wrapper })
|
||||||
|
const input = screen.getByPlaceholderText('Lobby Name') as HTMLInputElement
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: 'My Test Lobby' } })
|
||||||
|
|
||||||
|
expect(input.value).toBe('My Test Lobby')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates lobby successfully', async () => {
|
||||||
|
const mockLobbyResponse = {
|
||||||
|
data: {
|
||||||
|
id: 'lobby-123',
|
||||||
|
name: 'My Test Lobby',
|
||||||
|
owner: 'testuser'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mockedAxios.post.mockResolvedValueOnce(mockLobbyResponse)
|
||||||
|
|
||||||
|
render(<HomePage />, { wrapper: Wrapper })
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Lobby Name')
|
||||||
|
const button = screen.getByText('Create Lobby')
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: 'My Test Lobby' } })
|
||||||
|
fireEvent.click(button)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:3001/api/lobbies',
|
||||||
|
{ name: 'My Test Lobby', owner: 'testuser' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows alert on lobby creation failure', async () => {
|
||||||
|
// Mock alert
|
||||||
|
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => { })
|
||||||
|
|
||||||
|
mockedAxios.post.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
render(<HomePage />, { wrapper: Wrapper })
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Lobby Name')
|
||||||
|
const button = screen.getByText('Create Lobby')
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: 'My Test Lobby' } })
|
||||||
|
fireEvent.click(button)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(alertSpy).toHaveBeenCalledWith('Failed to create lobby')
|
||||||
|
})
|
||||||
|
|
||||||
|
alertSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
83
frontend/src/__tests__/components/auth/Login.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import Login from '../../../components/auth/Login'
|
||||||
|
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>
|
||||||
|
|
||||||
|
describe('Login', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders login form', () => {
|
||||||
|
render(<Login />)
|
||||||
|
expect(screen.getByText('Login')).toBeInTheDocument()
|
||||||
|
expect(screen.getByPlaceholderText('Username')).toBeInTheDocument()
|
||||||
|
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates username and password inputs', () => {
|
||||||
|
render(<Login />)
|
||||||
|
|
||||||
|
const usernameInput = screen.getByPlaceholderText('Username') as HTMLInputElement
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Password') as HTMLInputElement
|
||||||
|
|
||||||
|
fireEvent.change(usernameInput, { target: { value: 'testuser' } })
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'testpass' } })
|
||||||
|
|
||||||
|
expect(usernameInput.value).toBe('testuser')
|
||||||
|
expect(passwordInput.value).toBe('testpass')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits login form successfully', async () => {
|
||||||
|
// Mock alert
|
||||||
|
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => { })
|
||||||
|
|
||||||
|
mockedAxios.post.mockResolvedValueOnce({ data: 'Login successful' })
|
||||||
|
|
||||||
|
render(<Login />)
|
||||||
|
|
||||||
|
const usernameInput = screen.getByPlaceholderText('Username')
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Password')
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Login' })
|
||||||
|
|
||||||
|
fireEvent.change(usernameInput, { target: { value: 'testuser' } })
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'testpass' } })
|
||||||
|
fireEvent.click(submitButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:3001/api/users/login',
|
||||||
|
{ username: 'testuser', password: 'testpass' }
|
||||||
|
)
|
||||||
|
expect(alertSpy).toHaveBeenCalledWith('Login successful')
|
||||||
|
})
|
||||||
|
|
||||||
|
alertSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows alert on login failure', async () => {
|
||||||
|
// Mock alert
|
||||||
|
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => { })
|
||||||
|
|
||||||
|
mockedAxios.post.mockRejectedValueOnce(new Error('Invalid credentials'))
|
||||||
|
|
||||||
|
render(<Login />)
|
||||||
|
|
||||||
|
const usernameInput = screen.getByPlaceholderText('Username')
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Password')
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Login' })
|
||||||
|
|
||||||
|
fireEvent.change(usernameInput, { target: { value: 'testuser' } })
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'wrongpass' } })
|
||||||
|
fireEvent.click(submitButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(alertSpy).toHaveBeenCalledWith('Login failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
alertSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
83
frontend/src/__tests__/components/auth/Register.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import Register from '../../../components/auth/Register'
|
||||||
|
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>
|
||||||
|
|
||||||
|
describe('Register', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders register form', () => {
|
||||||
|
render(<Register />)
|
||||||
|
expect(screen.getByText('Register')).toBeInTheDocument()
|
||||||
|
expect(screen.getByPlaceholderText('Username')).toBeInTheDocument()
|
||||||
|
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Register' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates username and password inputs', () => {
|
||||||
|
render(<Register />)
|
||||||
|
|
||||||
|
const usernameInput = screen.getByPlaceholderText('Username') as HTMLInputElement
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Password') as HTMLInputElement
|
||||||
|
|
||||||
|
fireEvent.change(usernameInput, { target: { value: 'newuser' } })
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'newpass123' } })
|
||||||
|
|
||||||
|
expect(usernameInput.value).toBe('newuser')
|
||||||
|
expect(passwordInput.value).toBe('newpass123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits registration form successfully', async () => {
|
||||||
|
// Mock alert
|
||||||
|
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => { })
|
||||||
|
|
||||||
|
mockedAxios.post.mockResolvedValueOnce({ data: 'User created' })
|
||||||
|
|
||||||
|
render(<Register />)
|
||||||
|
|
||||||
|
const usernameInput = screen.getByPlaceholderText('Username')
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Password')
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Register' })
|
||||||
|
|
||||||
|
fireEvent.change(usernameInput, { target: { value: 'newuser' } })
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'newpass123' } })
|
||||||
|
fireEvent.click(submitButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:3001/api/users/register',
|
||||||
|
{ username: 'newuser', password: 'newpass123' }
|
||||||
|
)
|
||||||
|
expect(alertSpy).toHaveBeenCalledWith('Registration successful')
|
||||||
|
})
|
||||||
|
|
||||||
|
alertSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows alert on registration failure', async () => {
|
||||||
|
// Mock alert
|
||||||
|
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => { })
|
||||||
|
|
||||||
|
mockedAxios.post.mockRejectedValueOnce(new Error('User already exists'))
|
||||||
|
|
||||||
|
render(<Register />)
|
||||||
|
|
||||||
|
const usernameInput = screen.getByPlaceholderText('Username')
|
||||||
|
const passwordInput = screen.getByPlaceholderText('Password')
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Register' })
|
||||||
|
|
||||||
|
fireEvent.change(usernameInput, { target: { value: 'existinguser' } })
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'password123' } })
|
||||||
|
fireEvent.click(submitButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(alertSpy).toHaveBeenCalledWith('Registration failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
alertSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
124
frontend/src/components/AppNavigation.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { AppBar, Toolbar, Typography, Button, IconButton } from '@mui/material'
|
||||||
|
import { Games, AdminPanelSettings, Logout, Notifications, NotificationsOff } from '@mui/icons-material'
|
||||||
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
|
import Popover from '@mui/material/Popover'
|
||||||
|
import Stack from '@mui/material/Stack'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
const AppNavigation: React.FC = () => {
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
logout,
|
||||||
|
notificationsEnabled,
|
||||||
|
pushSubscribed,
|
||||||
|
subscribeToPushNotifications,
|
||||||
|
unsubscribeFromPushNotifications
|
||||||
|
} = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null // Don't show navigation if not logged in
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNotificationClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePopoverClose = () => {
|
||||||
|
setAnchorEl(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = Boolean(anchorEl)
|
||||||
|
const id = open ? 'notification-popover' : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBar position="static" elevation={0}>
|
||||||
|
<Toolbar>
|
||||||
|
<Games sx={{ mr: 2 }} />
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
component="div"
|
||||||
|
sx={{ flexGrow: 1, cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
>
|
||||||
|
Turn Tracker
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ mr: 1 }}>
|
||||||
|
{user.username}
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="Notification Status">
|
||||||
|
<IconButton color={pushSubscribed ? 'inherit' : 'error'} onClick={handleNotificationClick} sx={{ mr: 1 }}>
|
||||||
|
{pushSubscribed ? <Notifications /> : <NotificationsOff />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Popover
|
||||||
|
id={id}
|
||||||
|
open={open}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={handlePopoverClose}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
>
|
||||||
|
<Stack spacing={2} sx={{ p: 2, minWidth: 250 }}>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
<Typography sx={{ mr: 1 }}>🔔 Notification Status</Typography>
|
||||||
|
{!pushSubscribed && notificationsEnabled && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="warning"
|
||||||
|
onClick={() => { subscribeToPushNotifications(); handlePopoverClose() }}
|
||||||
|
>
|
||||||
|
Enable
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{pushSubscribed && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
onClick={() => { unsubscribeFromPushNotifications(); handlePopoverClose() }}
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Browser Notifications: {notificationsEnabled ? '✅ Enabled' : '❌ Disabled'}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||||
|
<Typography variant="body2">
|
||||||
|
Background Notifications: {pushSubscribed ? '✅ Subscribed' : '❌ Not Subscribed'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{user.isSuper && (
|
||||||
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
startIcon={<AdminPanelSettings />}
|
||||||
|
onClick={() => navigate('/admin')}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<IconButton color="inherit" onClick={handleLogout}>
|
||||||
|
<Logout />
|
||||||
|
</IconButton>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppNavigation
|
||||||
26
frontend/src/components/Player.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
interface PlayerProps {
|
||||||
|
player: {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const Player: React.FC<PlayerProps> = ({ player }) => {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: player.username });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||||
|
{player.username}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Player;
|
||||||
27
frontend/src/components/TurnPointer.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React, { CSSProperties } from 'react';
|
||||||
|
import { useDraggable } from '@dnd-kit/core';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
interface TurnPointerProps {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TurnPointer: React.FC<TurnPointerProps> = ({ id }) => {
|
||||||
|
const { attributes, listeners, setNodeRef, transform } = useDraggable({ id });
|
||||||
|
|
||||||
|
const style: CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
cursor: 'grab',
|
||||||
|
position: 'absolute' as React.CSSProperties['position'],
|
||||||
|
left: -20,
|
||||||
|
top: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
||||||
|
➡️
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TurnPointer;
|
||||||
38
frontend/src/components/auth/Login.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const Login: React.FC = () => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await axios.post('http://localhost:3001/api/users/login', { username, password });
|
||||||
|
alert('Login successful');
|
||||||
|
} catch (error) {
|
||||||
|
alert('Login failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<h2>Login</h2>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
38
frontend/src/components/auth/Register.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const Register: React.FC = () => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await axios.post('http://localhost:3001/api/users/register', { username, password });
|
||||||
|
alert('Registration successful');
|
||||||
|
} catch (error) {
|
||||||
|
alert('Registration failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<h2>Register</h2>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit">Register</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Register;
|
||||||
560
frontend/src/components/pages/AdminDashboard.tsx
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Alert,
|
||||||
|
Chip,
|
||||||
|
IconButton,
|
||||||
|
Collapse,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Snackbar
|
||||||
|
} from '@mui/material'
|
||||||
|
import {
|
||||||
|
Refresh,
|
||||||
|
ExpandMore,
|
||||||
|
ExpandLess,
|
||||||
|
Send,
|
||||||
|
Security
|
||||||
|
} from '@mui/icons-material'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import axios from 'axios'
|
||||||
|
import AppNavigation from '../AppNavigation'
|
||||||
|
|
||||||
|
interface TabPanelProps {
|
||||||
|
children?: React.ReactNode
|
||||||
|
index: number
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabPanel(props: TabPanelProps) {
|
||||||
|
const { children, value, index, ...other } = props
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={value !== index}
|
||||||
|
id={`admin-tabpanel-${index}`}
|
||||||
|
aria-labelledby={`admin-tab-${index}`}
|
||||||
|
{...other}
|
||||||
|
>
|
||||||
|
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardData {
|
||||||
|
users: Array<{
|
||||||
|
username: string
|
||||||
|
createdAt: string
|
||||||
|
lastLoginAt?: string
|
||||||
|
isSuper?: boolean
|
||||||
|
}>
|
||||||
|
lobbies: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
owner: string
|
||||||
|
playerCount: number
|
||||||
|
activePlayerCount: number
|
||||||
|
createdAt: string
|
||||||
|
lastActivity: string
|
||||||
|
currentPlayer?: string
|
||||||
|
recentActions: Array<{
|
||||||
|
id: string
|
||||||
|
timestamp: string
|
||||||
|
username: string
|
||||||
|
action: string
|
||||||
|
details?: string
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
totalUsers: number
|
||||||
|
totalLobbies: number
|
||||||
|
activeLobbies: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
totalUsers: number
|
||||||
|
totalLobbies: number
|
||||||
|
recentLogins24h: number
|
||||||
|
recentLogins7d: number
|
||||||
|
recentLobbies7d: number
|
||||||
|
activeLobbies24h: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminDashboard: React.FC = () => {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [currentTab, setCurrentTab] = useState(0)
|
||||||
|
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null)
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [expandedLobby, setExpandedLobby] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
const [passwordResetDialog, setPasswordResetDialog] = useState(false)
|
||||||
|
const [testNotificationDialog, setTestNotificationDialog] = useState(false)
|
||||||
|
const [selectedUser, setSelectedUser] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [notificationTitle, setNotificationTitle] = useState('Test Notification')
|
||||||
|
const [notificationMessage, setNotificationMessage] = useState('This is a test notification from the admin.')
|
||||||
|
|
||||||
|
// Feedback states
|
||||||
|
const [alert, setAlert] = useState<{ type: 'success' | 'error', message: string } | null>(null)
|
||||||
|
const [snackbar, setSnackbar] = useState<{ open: boolean, message: string }>({ open: false, message: '' })
|
||||||
|
|
||||||
|
// Redirect if not super admin
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || !user.isSuper) {
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
}, [user, navigate])
|
||||||
|
|
||||||
|
const fetchDashboardData = useCallback(async () => {
|
||||||
|
if (!user?.isSuper) {
|
||||||
|
setAlert({ type: 'error', message: 'Super admin access required' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const [dashboardResponse, statsResponse] = await Promise.all([
|
||||||
|
axios.post('http://localhost:3001/api/admin/dashboard', { username: user.username }),
|
||||||
|
axios.post('http://localhost:3001/api/admin/stats', { username: user.username })
|
||||||
|
])
|
||||||
|
setDashboardData(dashboardResponse.data)
|
||||||
|
setStats(statsResponse.data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch dashboard data:', error)
|
||||||
|
setAlert({ type: 'error', message: 'Failed to fetch dashboard data' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.isSuper) {
|
||||||
|
fetchDashboardData()
|
||||||
|
}
|
||||||
|
}, [user, fetchDashboardData])
|
||||||
|
|
||||||
|
const handlePasswordReset = async () => {
|
||||||
|
if (!selectedUser || !newPassword || !user?.isSuper) {
|
||||||
|
setAlert({ type: 'error', message: 'Please enter username and new password' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post('http://localhost:3001/api/admin/reset-password', {
|
||||||
|
username: user.username,
|
||||||
|
targetUsername: selectedUser,
|
||||||
|
newPassword
|
||||||
|
})
|
||||||
|
setAlert({ type: 'success', message: `Password reset successful for ${selectedUser}` })
|
||||||
|
setPasswordResetDialog(false)
|
||||||
|
setSelectedUser('')
|
||||||
|
setNewPassword('')
|
||||||
|
} catch (error) {
|
||||||
|
setAlert({ type: 'error', message: 'Failed to reset password' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTestNotification = async () => {
|
||||||
|
if (!user?.isSuper) {
|
||||||
|
setAlert({ type: 'error', message: 'Super admin access required' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedUser) {
|
||||||
|
setAlert({ type: 'error', message: 'Please select a user to send notification to' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post('http://localhost:3001/api/admin/send-push-notification', {
|
||||||
|
username: user.username,
|
||||||
|
targetUsername: selectedUser,
|
||||||
|
title: notificationTitle,
|
||||||
|
message: notificationMessage
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setSnackbar({ open: true, message: response.data.message })
|
||||||
|
setTestNotificationDialog(false)
|
||||||
|
setSelectedUser('')
|
||||||
|
setNotificationTitle('Test Notification')
|
||||||
|
setNotificationMessage('This is a test notification from the admin.')
|
||||||
|
} else {
|
||||||
|
setAlert({ type: 'error', message: response.data.message })
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.response?.data?.message || 'Failed to send test notification'
|
||||||
|
setAlert({ type: 'error', message: errorMessage })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTimeAgo = (dateString: string) => {
|
||||||
|
const now = new Date()
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
|
if (diffDays > 0) return `${diffDays}d ago`
|
||||||
|
if (diffHours > 0) return `${diffHours}h ago`
|
||||||
|
return 'Recent'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user?.isSuper) {
|
||||||
|
return null // Will redirect in useEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppNavigation />
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||||
|
<Typography variant="h4" component="h1">
|
||||||
|
Super Admin Dashboard
|
||||||
|
</Typography>
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
startIcon={<Refresh />}
|
||||||
|
onClick={fetchDashboardData}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{alert && (
|
||||||
|
<Alert
|
||||||
|
severity={alert.type}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
onClose={() => setAlert(null)}
|
||||||
|
>
|
||||||
|
{alert.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
{stats && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mb: 3 }}>
|
||||||
|
<Box sx={{ flex: '1 1 200px', minWidth: '150px' }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="h4">{stats.totalUsers}</Typography>
|
||||||
|
<Typography color="textSecondary">Total Users</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: '1 1 200px', minWidth: '150px' }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="h4">{stats.totalLobbies}</Typography>
|
||||||
|
<Typography color="textSecondary">Total Lobbies</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: '1 1 200px', minWidth: '150px' }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="h4">{stats.recentLogins24h}</Typography>
|
||||||
|
<Typography color="textSecondary">Logins (24h)</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: '1 1 200px', minWidth: '150px' }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="h4">{stats.recentLogins7d}</Typography>
|
||||||
|
<Typography color="textSecondary">Logins (7d)</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: '1 1 200px', minWidth: '150px' }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="h4">{stats.activeLobbies24h}</Typography>
|
||||||
|
<Typography color="textSecondary">Active Lobbies</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: '1 1 200px', minWidth: '150px' }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="h4">{stats.recentLobbies7d}</Typography>
|
||||||
|
<Typography color="textSecondary">New Lobbies (7d)</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<Security />}
|
||||||
|
onClick={() => setPasswordResetDialog(true)}
|
||||||
|
variant="contained"
|
||||||
|
sx={{ mr: 2 }}
|
||||||
|
>
|
||||||
|
Reset User Password
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<Send />}
|
||||||
|
onClick={() => setTestNotificationDialog(true)}
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
|
Send Test Notification
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tabs value={currentTab} onChange={(_, newValue) => setCurrentTab(newValue)}>
|
||||||
|
<Tab label="Users" />
|
||||||
|
<Tab label="Lobbies" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Users Tab */}
|
||||||
|
<TabPanel value={currentTab} index={0}>
|
||||||
|
{dashboardData && (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Username</TableCell>
|
||||||
|
<TableCell>Created</TableCell>
|
||||||
|
<TableCell>Last Login</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{dashboardData.users.map((user) => (
|
||||||
|
<TableRow key={user.username}>
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{user.username}
|
||||||
|
{user.isSuper && (
|
||||||
|
<Chip
|
||||||
|
label="Admin"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(user.createdAt)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.lastLoginAt ? formatDate(user.lastLoginAt) : 'Never'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={getTimeAgo(user.lastLoginAt || user.createdAt)}
|
||||||
|
color={user.lastLoginAt && getTimeAgo(user.lastLoginAt) === 'Recent' ? 'success' : 'default'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Send />}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUser(user.username)
|
||||||
|
setTestNotificationDialog(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Send Test
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Lobbies Tab */}
|
||||||
|
<TabPanel value={currentTab} index={1}>
|
||||||
|
{dashboardData && (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Owner</TableCell>
|
||||||
|
<TableCell>Players</TableCell>
|
||||||
|
<TableCell>Current Turn</TableCell>
|
||||||
|
<TableCell>Last Activity</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{dashboardData.lobbies.map((lobby) => (
|
||||||
|
<React.Fragment key={lobby.id}>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>{lobby.name}</TableCell>
|
||||||
|
<TableCell>{lobby.owner}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{lobby.activePlayerCount}/{lobby.playerCount}
|
||||||
|
<Chip
|
||||||
|
label={lobby.activePlayerCount > 0 ? 'Active' : 'Inactive'}
|
||||||
|
color={lobby.activePlayerCount > 0 ? 'success' : 'default'}
|
||||||
|
size="small"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{lobby.currentPlayer || 'N/A'}</TableCell>
|
||||||
|
<TableCell>{getTimeAgo(lobby.lastActivity)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setExpandedLobby(expandedLobby === lobby.id ? null : lobby.id)}
|
||||||
|
>
|
||||||
|
{expandedLobby === lobby.id ? <ExpandLess /> : <ExpandMore />}
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} sx={{ py: 0 }}>
|
||||||
|
<Collapse in={expandedLobby === lobby.id}>
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Recent Actions
|
||||||
|
</Typography>
|
||||||
|
<List dense>
|
||||||
|
{lobby.recentActions.length > 0 ? (
|
||||||
|
lobby.recentActions.map((action) => (
|
||||||
|
<ListItem key={action.id}>
|
||||||
|
<ListItemText
|
||||||
|
primary={`${action.username}: ${action.action}`}
|
||||||
|
secondary={`${action.details || ''} - ${formatDate(action.timestamp)}`}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="No recent actions" />
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Password Reset Dialog */}
|
||||||
|
<Dialog open={passwordResetDialog} onClose={() => setPasswordResetDialog(false)}>
|
||||||
|
<DialogTitle>Reset User Password</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Username"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={selectedUser}
|
||||||
|
onChange={(e) => setSelectedUser(e.target.value)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="New Password"
|
||||||
|
type="password"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setPasswordResetDialog(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handlePasswordReset} variant="contained">Reset Password</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Test Notification Dialog */}
|
||||||
|
<Dialog open={testNotificationDialog} onClose={() => setTestNotificationDialog(false)}>
|
||||||
|
<DialogTitle>
|
||||||
|
Send Test Notification
|
||||||
|
{selectedUser && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
Target: {selectedUser}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Title"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={notificationTitle}
|
||||||
|
onChange={(e) => setNotificationTitle(e.target.value)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Message"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
variant="outlined"
|
||||||
|
value={notificationMessage}
|
||||||
|
onChange={(e) => setNotificationMessage(e.target.value)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setTestNotificationDialog(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleTestNotification} variant="contained">Send Notification</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Snackbar for notifications */}
|
||||||
|
<Snackbar
|
||||||
|
open={snackbar.open}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||||
|
message={snackbar.message}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminDashboard
|
||||||
560
frontend/src/components/pages/HomePage.tsx
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Stack,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemButton,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Add, PlayArrow, People } from '@mui/icons-material'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
import AppNavigation from '../AppNavigation'
|
||||||
|
|
||||||
|
interface TabPanelProps {
|
||||||
|
children?: React.ReactNode
|
||||||
|
index: number
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Player {
|
||||||
|
username: string
|
||||||
|
left?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Lobby {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
owner: string
|
||||||
|
players: Player[]
|
||||||
|
turn: number
|
||||||
|
inviteCode: string
|
||||||
|
password?: string
|
||||||
|
createdAt: string
|
||||||
|
lastActivity: string
|
||||||
|
locked?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabPanel(props: TabPanelProps) {
|
||||||
|
const { children, value, index, ...other } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={value !== index}
|
||||||
|
id={`simple-tabpanel-${index}`}
|
||||||
|
aria-labelledby={`simple-tab-${index}`}
|
||||||
|
{...other}
|
||||||
|
>
|
||||||
|
{value === index && (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const HomePage: React.FC = () => {
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
isLoading: authLoading,
|
||||||
|
checkAndClearPendingInvite,
|
||||||
|
notificationsEnabled,
|
||||||
|
pushSubscribed,
|
||||||
|
subscribeToPushNotifications,
|
||||||
|
unsubscribeFromPushNotifications
|
||||||
|
} = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// Auth form states
|
||||||
|
const [tabValue, setTabValue] = useState(0)
|
||||||
|
const [loginData, setLoginData] = useState({ username: '', password: '' })
|
||||||
|
const [registerData, setRegisterData] = useState({ username: '', password: '', confirmPassword: '' })
|
||||||
|
const [authError, setAuthError] = useState('')
|
||||||
|
const [authSuccess, setAuthSuccess] = useState('')
|
||||||
|
|
||||||
|
// Lobby creation states
|
||||||
|
const [lobbyName, setLobbyName] = useState('')
|
||||||
|
const [lobbyDescription, setLobbyDescription] = useState('')
|
||||||
|
const [lobbyLoading, setLobbyLoading] = useState(false)
|
||||||
|
const [lobbyError, setLobbyError] = useState('')
|
||||||
|
|
||||||
|
// User lobbies states
|
||||||
|
const [userLobbies, setUserLobbies] = useState<Lobby[]>([])
|
||||||
|
const [lobbiesLoading, setLobbiesLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchUserLobbies = useCallback(async () => {
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLobbiesLoading(true)
|
||||||
|
const response = await axios.get(`http://localhost:3001/api/lobbies/user/${user.username}`)
|
||||||
|
setUserLobbies(response.data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user lobbies:', error)
|
||||||
|
} finally {
|
||||||
|
setLobbiesLoading(false)
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
fetchUserLobbies()
|
||||||
|
|
||||||
|
// Check for pending invite after login/register
|
||||||
|
const pendingInvite = checkAndClearPendingInvite()
|
||||||
|
if (pendingInvite) {
|
||||||
|
// Find lobby by invite code and redirect
|
||||||
|
axios.get('http://localhost:3001/api/lobbies')
|
||||||
|
.then(response => {
|
||||||
|
const lobby = response.data.find((l: any) => l.inviteCode === pendingInvite)
|
||||||
|
if (lobby) {
|
||||||
|
localStorage.setItem('pendingInvite', pendingInvite)
|
||||||
|
navigate(`/lobby/${lobby.id}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [user, fetchUserLobbies, checkAndClearPendingInvite, navigate])
|
||||||
|
|
||||||
|
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||||
|
setTabValue(newValue)
|
||||||
|
setAuthError('')
|
||||||
|
setAuthSuccess('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setAuthError('')
|
||||||
|
setAuthSuccess('')
|
||||||
|
|
||||||
|
if (!loginData.username || !loginData.password) {
|
||||||
|
setAuthError('Please fill in all fields')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await login(loginData.username, loginData.password)
|
||||||
|
if (success) {
|
||||||
|
setAuthSuccess('Login successful!')
|
||||||
|
setLoginData({ username: '', password: '' })
|
||||||
|
} else {
|
||||||
|
setAuthError('Invalid username or password')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setAuthError('')
|
||||||
|
setAuthSuccess('')
|
||||||
|
|
||||||
|
if (!registerData.username || !registerData.password || !registerData.confirmPassword) {
|
||||||
|
setAuthError('Please fill in all fields')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registerData.password !== registerData.confirmPassword) {
|
||||||
|
setAuthError('Passwords do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registerData.password.length < 6) {
|
||||||
|
setAuthError('Password must be at least 6 characters long')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await register(registerData.username, registerData.password)
|
||||||
|
if (success) {
|
||||||
|
setAuthSuccess('Registration successful! You are now logged in.')
|
||||||
|
setRegisterData({ username: '', password: '', confirmPassword: '' })
|
||||||
|
} else {
|
||||||
|
setAuthError('Registration failed. Username may already be taken.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateLobby = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
|
setLobbyLoading(true)
|
||||||
|
setLobbyError('')
|
||||||
|
|
||||||
|
// Validate lobby name is provided
|
||||||
|
if (!lobbyName.trim()) {
|
||||||
|
setLobbyError('Lobby name is required')
|
||||||
|
setLobbyLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post('http://localhost:3001/api/lobbies', {
|
||||||
|
name: lobbyName.trim(),
|
||||||
|
description: lobbyDescription.trim() || undefined,
|
||||||
|
owner: user.username,
|
||||||
|
})
|
||||||
|
fetchUserLobbies() // Refresh lobbies after creating a new one
|
||||||
|
navigate(`/lobby/${response.data.id}`)
|
||||||
|
} catch (error) {
|
||||||
|
setLobbyError('Failed to create lobby. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setLobbyLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh">
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppNavigation />
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
{!user ? (
|
||||||
|
<Box display="flex" justifyContent="center">
|
||||||
|
<Box sx={{ width: { xs: '100%', md: '50%' } }}>
|
||||||
|
<Card elevation={3}>
|
||||||
|
<CardContent>
|
||||||
|
<Box textAlign="center" mb={3}>
|
||||||
|
{/* Games icon removed as per new_code */}
|
||||||
|
<Typography variant="h1" gutterBottom>
|
||||||
|
Turn Tracker
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Manage turns for your games with ease.
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, fontStyle: 'italic' }}>
|
||||||
|
💡 We'll automatically request notification permissions after login to keep you updated when it's your turn!
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Tabs value={tabValue} onChange={handleTabChange} centered>
|
||||||
|
<Tab label="Login" />
|
||||||
|
<Tab label="Register" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{authError && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
{authError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authSuccess && (
|
||||||
|
<Alert severity="success" sx={{ mt: 2 }}>
|
||||||
|
{authSuccess}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TabPanel value={tabValue} index={0}>
|
||||||
|
<Box component="form" onSubmit={handleLogin}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Username"
|
||||||
|
value={loginData.username}
|
||||||
|
onChange={(e) => setLoginData({ ...loginData, username: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={loginData.password}
|
||||||
|
onChange={(e) => setLoginData({ ...loginData, password: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
disabled={authLoading}
|
||||||
|
>
|
||||||
|
{authLoading ? <CircularProgress size={24} /> : 'Login'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={tabValue} index={1}>
|
||||||
|
<Box component="form" onSubmit={handleRegister}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Username"
|
||||||
|
value={registerData.username}
|
||||||
|
onChange={(e) => setRegisterData({ ...registerData, username: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={registerData.password}
|
||||||
|
onChange={(e) => setRegisterData({ ...registerData, password: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
helperText="Minimum 6 characters"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Confirm Password"
|
||||||
|
type="password"
|
||||||
|
value={registerData.confirmPassword}
|
||||||
|
onChange={(e) => setRegisterData({ ...registerData, confirmPassword: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
disabled={authLoading}
|
||||||
|
>
|
||||||
|
{authLoading ? <CircularProgress size={24} /> : 'Register'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Box textAlign="center">
|
||||||
|
<Typography variant="h2" gutterBottom>
|
||||||
|
Welcome to Turn Tracker
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Create a new lobby or join an existing one to start managing turns for your games.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* User's Current Lobbies */}
|
||||||
|
{lobbiesLoading ? (
|
||||||
|
<Box display="flex" justifyContent="center">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : userLobbies.length > 0 ? (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{/* Active Lobbies */}
|
||||||
|
{userLobbies.filter(lobby =>
|
||||||
|
!lobby.players.find(p => p.username === user?.username)?.left
|
||||||
|
).length > 0 && (
|
||||||
|
<Card elevation={3}>
|
||||||
|
<CardContent>
|
||||||
|
<Box textAlign="center" mb={3}>
|
||||||
|
<Typography variant="h3" gutterBottom>
|
||||||
|
Your Active Lobbies
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Click on any lobby to join and continue your game
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<List>
|
||||||
|
{userLobbies.filter(lobby =>
|
||||||
|
!lobby.players.find(p => p.username === user?.username)?.left
|
||||||
|
).map((lobby, index, filteredArray) => (
|
||||||
|
<React.Fragment key={lobby.id}>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
onClick={() => navigate(`/lobby/${lobby.id}`)}
|
||||||
|
sx={{ borderRadius: 1 }}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||||
|
<PlayArrow color="primary" sx={{ mr: 2 }} />
|
||||||
|
<ListItemText
|
||||||
|
primary={lobby.name}
|
||||||
|
secondary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
|
||||||
|
<People fontSize="small" />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{lobby.players.filter((p: any) => !p.left).length} active players
|
||||||
|
</Typography>
|
||||||
|
{lobby.players[lobby.turn] && (
|
||||||
|
<Chip
|
||||||
|
label={`${lobby.players[lobby.turn].username}'s turn`}
|
||||||
|
size="small"
|
||||||
|
color={lobby.players[lobby.turn].username === user?.username ? 'primary' : 'default'}
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
{index < filteredArray.length - 1 && <Divider />}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Left Lobbies */}
|
||||||
|
{userLobbies.filter(lobby =>
|
||||||
|
lobby.players.find(p => p.username === user?.username)?.left
|
||||||
|
).length > 0 && (
|
||||||
|
<Card elevation={3} sx={{ border: '2px solid', borderColor: 'warning.main' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box textAlign="center" mb={3}>
|
||||||
|
<Typography variant="h3" gutterBottom color="warning.main">
|
||||||
|
⚠️ Lobbies You've Left
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
You are no longer active in these games. You'll need to be re-invited to rejoin.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<List>
|
||||||
|
{userLobbies.filter(lobby =>
|
||||||
|
lobby.players.find(p => p.username === user?.username)?.left
|
||||||
|
).map((lobby, index, filteredArray) => (
|
||||||
|
<React.Fragment key={lobby.id}>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
onClick={() => navigate(`/lobby/${lobby.id}`)}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: 'warning.light',
|
||||||
|
opacity: 0.7,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'warning.main',
|
||||||
|
opacity: 0.8,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||||
|
<Box sx={{ mr: 2, color: 'warning.dark' }}>❌</Box>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="h6" sx={{ textDecoration: 'line-through' }}>
|
||||||
|
{lobby.name}
|
||||||
|
</Typography>
|
||||||
|
<Chip label="LEFT" size="small" color="warning" />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
|
||||||
|
<People fontSize="small" />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{lobby.players.filter((p: any) => !p.left).length} active players remaining
|
||||||
|
</Typography>
|
||||||
|
{lobby.players[lobby.turn] && !lobby.players[lobby.turn].left && (
|
||||||
|
<Chip
|
||||||
|
label={`${lobby.players[lobby.turn].username}'s turn`}
|
||||||
|
size="small"
|
||||||
|
color="default"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
{index < filteredArray.length - 1 && <Divider />}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Box display="flex" justifyContent="center">
|
||||||
|
<Box sx={{ width: { xs: '100%', md: '50%' } }}>
|
||||||
|
<Card elevation={3}>
|
||||||
|
<CardContent>
|
||||||
|
<Box textAlign="center" mb={3}>
|
||||||
|
<Add color="primary" sx={{ fontSize: 48, mb: 2 }} />
|
||||||
|
<Typography variant="h3" gutterBottom>
|
||||||
|
Create New Lobby
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Start a new game session and invite your friends
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{lobbyError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{lobbyError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleCreateLobby}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Lobby Name"
|
||||||
|
value={lobbyName}
|
||||||
|
onChange={(e) => setLobbyName(e.target.value)}
|
||||||
|
placeholder="My Awesome Game"
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
helperText="Enter a name for your lobby"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Description (Optional)"
|
||||||
|
value={lobbyDescription}
|
||||||
|
onChange={(e) => setLobbyDescription(e.target.value)}
|
||||||
|
placeholder="What's this game about?"
|
||||||
|
margin="normal"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
helperText="Add details about your game or rules"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
disabled={lobbyLoading}
|
||||||
|
startIcon={lobbyLoading ? <CircularProgress size={20} /> : <Add />}
|
||||||
|
>
|
||||||
|
{lobbyLoading ? 'Creating...' : 'Create Lobby'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomePage
|
||||||
204
frontend/src/components/pages/JoinLobbyPage.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Lock, Group } from '@mui/icons-material'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
import AppNavigation from '../AppNavigation'
|
||||||
|
|
||||||
|
const JoinLobbyPage: React.FC = () => {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { inviteCode } = useParams<{ inviteCode: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [needsPassword, setNeedsPassword] = useState(false)
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
|
||||||
|
const joinLobby = useCallback(async (lobbyPassword?: string) => {
|
||||||
|
if (!user || !inviteCode) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post('http://localhost:3001/api/lobbies/join', {
|
||||||
|
inviteCode,
|
||||||
|
username: user.username,
|
||||||
|
password: lobbyPassword,
|
||||||
|
})
|
||||||
|
|
||||||
|
navigate(`/lobby/${response.data.id}`)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
setNeedsPassword(true)
|
||||||
|
setError('This lobby requires a password.')
|
||||||
|
} else if (error.response?.status === 400) {
|
||||||
|
if (error.response.data.includes('full')) {
|
||||||
|
setError('This lobby is full (maximum 10 players).')
|
||||||
|
} else {
|
||||||
|
setError('Failed to join lobby. Please check the invite link.')
|
||||||
|
}
|
||||||
|
} else if (error.response?.status === 404) {
|
||||||
|
setError('Lobby not found. The invite link may be invalid or expired.')
|
||||||
|
} else {
|
||||||
|
setError('Failed to join lobby. Please try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [user, inviteCode, navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
// Store the invite code and redirect to login
|
||||||
|
localStorage.setItem('pendingInvite', inviteCode || '')
|
||||||
|
navigate('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user is logged in, redirect to lobby page instead of auto-joining
|
||||||
|
if (inviteCode) {
|
||||||
|
// Find the lobby by invite code first
|
||||||
|
axios.get(`http://localhost:3001/api/lobbies`)
|
||||||
|
.then(response => {
|
||||||
|
const lobby = response.data.find((l: any) => l.inviteCode === inviteCode)
|
||||||
|
if (lobby) {
|
||||||
|
localStorage.setItem('pendingInvite', inviteCode)
|
||||||
|
navigate(`/lobby/${lobby.id}`)
|
||||||
|
} else {
|
||||||
|
setError('Lobby not found. The invite link may be invalid or expired.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Failed to find lobby. Please try again.')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [inviteCode, user, navigate])
|
||||||
|
|
||||||
|
const handlePasswordSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!password.trim()) {
|
||||||
|
setError('Please enter the lobby password.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
joinLobby(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppNavigation />
|
||||||
|
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||||
|
<Box textAlign="center">
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
<Typography variant="h6" sx={{ mt: 2 }}>
|
||||||
|
Redirecting to login...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppNavigation />
|
||||||
|
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||||
|
<Box textAlign="center" mb={4}>
|
||||||
|
<Group color="primary" sx={{ fontSize: 64, mb: 2 }} />
|
||||||
|
<Typography variant="h3" gutterBottom>
|
||||||
|
Joining Lobby
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Please wait while we add you to the game...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Box display="flex" justifyContent="center" mb={4}>
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 4 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && error && (
|
||||||
|
<Box textAlign="center">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
sx={{ mr: 2 }}
|
||||||
|
>
|
||||||
|
Go Home
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => joinLobby()}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Password Dialog */}
|
||||||
|
<Dialog open={needsPassword} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<Lock color="primary" />
|
||||||
|
Password Required
|
||||||
|
</Box>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body1" sx={{ mb: 3 }}>
|
||||||
|
This lobby is password protected. Please enter the password to join.
|
||||||
|
</Typography>
|
||||||
|
<Box component="form" onSubmit={handlePasswordSubmit}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Lobby Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => navigate('/')}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handlePasswordSubmit}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!password.trim() || loading}
|
||||||
|
>
|
||||||
|
{loading ? <CircularProgress size={20} /> : 'Join Lobby'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JoinLobbyPage
|
||||||
812
frontend/src/components/pages/LobbyPage.tsx
Normal file
@@ -0,0 +1,812 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
Chip,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemAvatar,
|
||||||
|
Avatar,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Snackbar,
|
||||||
|
Divider,
|
||||||
|
Tooltip,
|
||||||
|
Stack,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material'
|
||||||
|
import {
|
||||||
|
ContentCopy,
|
||||||
|
DragIndicator,
|
||||||
|
Delete,
|
||||||
|
Share,
|
||||||
|
PlayArrow,
|
||||||
|
Stop,
|
||||||
|
Lock,
|
||||||
|
LockOpen,
|
||||||
|
SkipNext,
|
||||||
|
KeyboardDoubleArrowRight,
|
||||||
|
} from '@mui/icons-material'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
DragEndEvent,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
PointerSensor,
|
||||||
|
KeyboardSensor
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
arrayMove,
|
||||||
|
sortableKeyboardCoordinates
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
import AppNavigation from '../AppNavigation'
|
||||||
|
|
||||||
|
interface PlayerData {
|
||||||
|
username: string
|
||||||
|
left?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Lobby {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
owner: string
|
||||||
|
players: PlayerData[]
|
||||||
|
turn: number
|
||||||
|
inviteCode: string
|
||||||
|
password?: string
|
||||||
|
locked?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortablePlayerProps {
|
||||||
|
player: PlayerData
|
||||||
|
index: number
|
||||||
|
isCurrentTurn: boolean
|
||||||
|
isOwner: boolean
|
||||||
|
canManage: boolean
|
||||||
|
locked: boolean
|
||||||
|
onRemove: (username: string) => void
|
||||||
|
onSetCurrentTurn: (username: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortablePlayer: React.FC<SortablePlayerProps> = ({
|
||||||
|
player,
|
||||||
|
index,
|
||||||
|
isCurrentTurn,
|
||||||
|
isOwner,
|
||||||
|
canManage,
|
||||||
|
locked,
|
||||||
|
onRemove,
|
||||||
|
onSetCurrentTurn
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
} = useSortable({ id: player.username, disabled: locked })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
sx={{
|
||||||
|
mb: 1,
|
||||||
|
bgcolor: isCurrentTurn ? 'primary.light' : 'background.paper',
|
||||||
|
borderRadius: 2,
|
||||||
|
boxShadow: isCurrentTurn ? 2 : 1,
|
||||||
|
border: isCurrentTurn ? '2px solid' : '1px solid',
|
||||||
|
borderColor: isCurrentTurn ? 'primary.main' : 'divider',
|
||||||
|
opacity: player.left ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
secondaryAction={
|
||||||
|
canManage && !locked && !player.left && (
|
||||||
|
<Box display="flex" gap={1}>
|
||||||
|
{!isCurrentTurn && (
|
||||||
|
<Tooltip title="Set as current turn">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onSetCurrentTurn(player.username)}
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<KeyboardDoubleArrowRight />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title="Remove player">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onRemove(player.username)}
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{canManage && (
|
||||||
|
<ListItemAvatar
|
||||||
|
{...(!locked ? attributes : {})}
|
||||||
|
{...(!locked ? listeners : {})}
|
||||||
|
sx={{ cursor: locked ? 'default' : 'grab' }}
|
||||||
|
>
|
||||||
|
<Avatar sx={{
|
||||||
|
bgcolor: isCurrentTurn ? 'primary.main' : 'grey.400',
|
||||||
|
opacity: locked ? 0.5 : 1
|
||||||
|
}}>
|
||||||
|
<DragIndicator />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
textDecoration: player.left ? 'line-through' : 'none',
|
||||||
|
color: isCurrentTurn ? 'primary.contrastText' : 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{player.username}
|
||||||
|
</Typography>
|
||||||
|
{isOwner && <Chip label="Owner" size="small" color="secondary" />}
|
||||||
|
{isCurrentTurn && <Chip label="Current Turn" size="small" color="primary" />}
|
||||||
|
{player.left && <Chip label="Left" size="small" variant="outlined" />}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={isCurrentTurn ? "It's your turn!" : `Position ${index + 1}`}
|
||||||
|
sx={{
|
||||||
|
'& .MuiListItemText-secondary': {
|
||||||
|
color: isCurrentTurn ? 'primary.contrastText' : 'text.secondary',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LobbyPage: React.FC = () => {
|
||||||
|
const { user, notificationsEnabled } = useAuth()
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// State management
|
||||||
|
const [lobby, setLobby] = useState<Lobby | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [snackbar, setSnackbar] = useState<{ open: boolean, message: string, severity: 'success' | 'error' }>({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
severity: 'success'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const [newGameName, setNewGameName] = useState('')
|
||||||
|
const [newDescription, setNewDescription] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
|
||||||
|
// Leave confirmation
|
||||||
|
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false)
|
||||||
|
|
||||||
|
// Invite confirmation
|
||||||
|
const [inviteConfirmOpen, setInviteConfirmOpen] = useState(false)
|
||||||
|
const [inviteCode, setInviteCode] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchLobby = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`http://localhost:3001/api/lobbies/${id}`)
|
||||||
|
setLobby(response.data)
|
||||||
|
setNewGameName(response.data.name)
|
||||||
|
setNewDescription(response.data.description || '')
|
||||||
|
|
||||||
|
// Check for pending invite after loading lobby
|
||||||
|
const pendingInvite = localStorage.getItem('pendingInvite')
|
||||||
|
if (pendingInvite && response.data.inviteCode === pendingInvite) {
|
||||||
|
// Check if user is already in the lobby
|
||||||
|
const userInLobby = response.data.players.some((p: any) => p.username === user?.username && !p.left)
|
||||||
|
if (!userInLobby) {
|
||||||
|
setInviteCode(pendingInvite)
|
||||||
|
setInviteConfirmOpen(true)
|
||||||
|
}
|
||||||
|
localStorage.removeItem('pendingInvite')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError('Failed to load lobby')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [id, user?.username])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
navigate('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchLobby()
|
||||||
|
|
||||||
|
// WebSocket connection
|
||||||
|
const ws = new WebSocket('ws://localhost:3001')
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const message = JSON.parse(event.data)
|
||||||
|
if (message.lobby?.id === id) {
|
||||||
|
setLobby(message.lobby)
|
||||||
|
setNewGameName(message.lobby.name)
|
||||||
|
|
||||||
|
// Show notification for turn changes
|
||||||
|
if (message.type === 'turn-change' && message.lobby.players[message.lobby.turn]?.username === user?.username) {
|
||||||
|
showSnackbar("It's your turn!", 'success')
|
||||||
|
if (notificationsEnabled && Notification.permission === 'granted') {
|
||||||
|
new Notification("Your Turn!", {
|
||||||
|
body: `It's your turn in ${message.lobby.name}`,
|
||||||
|
icon: '/logo192.png',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polling backup every 5 minutes
|
||||||
|
const interval = setInterval(fetchLobby, 5 * 60 * 1000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ws.close()
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [id, user, navigate, notificationsEnabled, fetchLobby])
|
||||||
|
|
||||||
|
const showSnackbar = (message: string, severity: 'success' | 'error' = 'success') => {
|
||||||
|
setSnackbar({ open: true, message, severity })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleEndTurn = async () => {
|
||||||
|
try {
|
||||||
|
await axios.post(`http://localhost:3001/api/lobbies/${id}/end-turn`, {
|
||||||
|
username: user?.username
|
||||||
|
})
|
||||||
|
showSnackbar('Turn advanced!')
|
||||||
|
} catch (error) {
|
||||||
|
showSnackbar('Failed to end turn', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLockToggle = async () => {
|
||||||
|
if (!lobby || !user || lobby.owner !== user.username) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = lobby.locked ? 'unlock' : 'lock'
|
||||||
|
await axios.post(`http://localhost:3001/api/lobbies/${id}/${endpoint}`, {
|
||||||
|
username: user.username
|
||||||
|
})
|
||||||
|
const action = lobby.locked ? 'unlocked' : 'locked'
|
||||||
|
showSnackbar(`Turn order ${action}!`, 'success')
|
||||||
|
fetchLobby()
|
||||||
|
} catch (error) {
|
||||||
|
showSnackbar('Failed to change lock state', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLeaveLobby = () => {
|
||||||
|
setLeaveConfirmOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmLeaveLobby = async () => {
|
||||||
|
if (!user) return
|
||||||
|
try {
|
||||||
|
await axios.post(`http://localhost:3001/api/lobbies/${id}/leave`, { username: user.username })
|
||||||
|
showSnackbar('Left lobby')
|
||||||
|
navigate('/')
|
||||||
|
} catch (error) {
|
||||||
|
showSnackbar('Failed to leave lobby', 'error')
|
||||||
|
}
|
||||||
|
setLeaveConfirmOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleJoinFromInvite = async (password?: string) => {
|
||||||
|
if (!user || !inviteCode) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post('http://localhost:3001/api/lobbies/join', {
|
||||||
|
inviteCode,
|
||||||
|
username: user.username,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
setInviteConfirmOpen(false)
|
||||||
|
setInviteCode(null)
|
||||||
|
showSnackbar('Successfully joined the lobby!', 'success')
|
||||||
|
fetchLobby() // Refresh lobby data
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
showSnackbar('Incorrect password. Please try again.', 'error')
|
||||||
|
} else if (error.response?.status === 400) {
|
||||||
|
if (error.response.data.includes('full')) {
|
||||||
|
showSnackbar('This lobby is full (maximum 10 players).', 'error')
|
||||||
|
setInviteConfirmOpen(false)
|
||||||
|
setInviteCode(null)
|
||||||
|
} else {
|
||||||
|
showSnackbar('Failed to join lobby. Please try again.', 'error')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showSnackbar('Failed to join lobby. Please try again.', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeclineInvite = () => {
|
||||||
|
setInviteConfirmOpen(false)
|
||||||
|
setInviteCode(null)
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemovePlayer = async (username: string) => {
|
||||||
|
try {
|
||||||
|
await axios.post(`http://localhost:3001/api/lobbies/${id}/remove`, { username, removedBy: user?.username })
|
||||||
|
showSnackbar(`Removed ${username} from lobby`)
|
||||||
|
} catch (error) {
|
||||||
|
showSnackbar('Failed to remove player', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetCurrentTurn = async (username: string) => {
|
||||||
|
if (!user) return
|
||||||
|
try {
|
||||||
|
await axios.post(`http://localhost:3001/api/lobbies/${id}/set-current-turn`, {
|
||||||
|
targetUsername: username,
|
||||||
|
username: user.username
|
||||||
|
})
|
||||||
|
showSnackbar(`Turn set to ${username}`)
|
||||||
|
} catch (error) {
|
||||||
|
showSnackbar('Failed to set current turn', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetPassword = async () => {
|
||||||
|
if (!user) return
|
||||||
|
try {
|
||||||
|
await axios.post(`http://localhost:3001/api/lobbies/${id}/set-password`, {
|
||||||
|
password: newPassword,
|
||||||
|
username: user.username
|
||||||
|
})
|
||||||
|
showSnackbar('Lobby password updated!')
|
||||||
|
setNewPassword('')
|
||||||
|
} catch (error) {
|
||||||
|
showSnackbar('Failed to set password', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveSettings = async () => {
|
||||||
|
if (!user) return
|
||||||
|
try {
|
||||||
|
// Update name if changed
|
||||||
|
if (newGameName !== lobby?.name) {
|
||||||
|
await axios.post(`http://localhost:3001/api/lobbies/${id}/rename`, {
|
||||||
|
newName: newGameName,
|
||||||
|
username: user.username
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update description if changed
|
||||||
|
if (newDescription !== (lobby?.description || '')) {
|
||||||
|
await axios.post(`http://localhost:3001/api/lobbies/${id}/set-description`, {
|
||||||
|
description: newDescription,
|
||||||
|
username: user.username
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
showSnackbar('Settings saved successfully!')
|
||||||
|
setSettingsOpen(false)
|
||||||
|
fetchLobby() // Refresh lobby data
|
||||||
|
} catch (error) {
|
||||||
|
showSnackbar('Failed to save settings', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
if (!lobby || lobby.locked) return
|
||||||
|
|
||||||
|
const { active, over } = event
|
||||||
|
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
const oldIndex = lobby.players.findIndex(p => p.username === active.id)
|
||||||
|
const newIndex = lobby.players.findIndex(p => p.username === over.id)
|
||||||
|
const newPlayers = arrayMove(lobby.players, oldIndex, newIndex)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post(`http://localhost:3001/api/lobbies/${id}/reorder`, {
|
||||||
|
players: newPlayers,
|
||||||
|
username: user?.username
|
||||||
|
})
|
||||||
|
showSnackbar('Player order updated!')
|
||||||
|
} catch (error) {
|
||||||
|
showSnackbar('Failed to reorder players', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyInviteLink = () => {
|
||||||
|
if (!lobby) return
|
||||||
|
const inviteLink = `${window.location.origin}/join/${lobby.inviteCode}`
|
||||||
|
navigator.clipboard.writeText(inviteLink)
|
||||||
|
showSnackbar('Invite link copied to clipboard!')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh">
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !lobby || !user) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||||
|
<Alert severity="error">
|
||||||
|
{error || 'Lobby not found'}
|
||||||
|
</Alert>
|
||||||
|
<Button onClick={() => navigate('/')} sx={{ mt: 2 }}>
|
||||||
|
Go Home
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = user?.username === lobby.owner
|
||||||
|
const currentPlayer = lobby.players[lobby.turn]
|
||||||
|
const isCurrentTurn = currentPlayer?.username === user?.username
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppNavigation />
|
||||||
|
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
{/* Lobby Header */}
|
||||||
|
<Box sx={{ mb: 4, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h2" gutterBottom sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||||
|
{lobby?.name}
|
||||||
|
</Typography>
|
||||||
|
{lobby?.description && (
|
||||||
|
<Typography variant="h6" color="text.secondary" sx={{ maxWidth: '800px', mx: 'auto' }}>
|
||||||
|
{lobby.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', md: 'row' },
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: { xs: 1, md: '2 1 0%' } }}>
|
||||||
|
<Card elevation={3}>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Turn Order
|
||||||
|
</Typography>
|
||||||
|
<Box>
|
||||||
|
<Tooltip title="Copy invite link">
|
||||||
|
<IconButton onClick={copyInviteLink} color="primary">
|
||||||
|
<Share />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
{isOwner && (
|
||||||
|
<Tooltip title={lobby.locked ? 'Unlock turn order' : 'Lock turn order'}>
|
||||||
|
<IconButton onClick={handleLockToggle} color={lobby.locked ? 'warning' : 'default'}>
|
||||||
|
{lobby.locked ? <Lock /> : <LockOpen />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{lobby.locked && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<Lock fontSize="small" />
|
||||||
|
Turn order is locked and cannot be changed
|
||||||
|
</Box>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lobby.players.length === 0 ? (
|
||||||
|
<Alert severity="info">No players in this lobby yet.</Alert>
|
||||||
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={lobby.players.map(p => p.username)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<List>
|
||||||
|
{lobby.players.map((player, index) => (
|
||||||
|
<SortablePlayer
|
||||||
|
key={player.username}
|
||||||
|
player={player}
|
||||||
|
index={index}
|
||||||
|
isCurrentTurn={lobby.turn === index}
|
||||||
|
isOwner={player.username === lobby.owner}
|
||||||
|
canManage={isOwner}
|
||||||
|
locked={lobby.locked || false}
|
||||||
|
onRemove={handleRemovePlayer}
|
||||||
|
onSetCurrentTurn={handleSetCurrentTurn}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: { xs: 1, md: '1 1 0%' } }}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Card elevation={3}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Game Actions
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
{isCurrentTurn && (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
startIcon={<PlayArrow />}
|
||||||
|
onClick={handleEndTurn}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
End My Turn
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
startIcon={<Stop />}
|
||||||
|
onClick={handleLeaveLobby}
|
||||||
|
>
|
||||||
|
Leave Lobby
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card elevation={3}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Lobby Info
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Box mb={2}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Players: {lobby.players.filter(p => !p.left).length}/{lobby.players.length}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Current Turn: {currentPlayer?.username || 'Unknown'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Owner: {lobby.owner}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<ContentCopy />}
|
||||||
|
onClick={copyInviteLink}
|
||||||
|
>
|
||||||
|
Copy Invite Link
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{/* Settings Dialog */}
|
||||||
|
<Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Lobby Settings</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Lobby Name"
|
||||||
|
value={newGameName}
|
||||||
|
onChange={(e) => setNewGameName(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Description (Optional)"
|
||||||
|
value={newDescription}
|
||||||
|
onChange={(e) => setNewDescription(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
placeholder="What's this game about?"
|
||||||
|
helperText="Add details about your game or rules"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="New Password (optional)"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
helperText="Leave empty to remove password"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setSettingsOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSetPassword} variant="outlined">
|
||||||
|
Update Password
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveSettings} variant="contained">
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Leave Confirmation Dialog */}
|
||||||
|
<Dialog open={leaveConfirmOpen} onClose={() => setLeaveConfirmOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle sx={{ color: 'error.main', fontWeight: 'bold' }}>
|
||||||
|
⚠️ Are you SUPER SUPER SUPER sure?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ textAlign: 'center', py: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ color: 'error.main' }}>
|
||||||
|
You're about to leave "{lobby?.name}"
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||||
|
This action cannot be undone! You will:
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ textAlign: 'left', mb: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
• ❌ Be marked as "left" in the lobby
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
• 🚫 No longer participate in turns
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
• 📱 Stop receiving notifications
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
• 🔄 Need to be re-invited to rejoin
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h6" sx={{ color: 'error.main', fontWeight: 'bold' }}>
|
||||||
|
Are you absolutely, positively, 100% certain?
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setLeaveConfirmOpen(false)} variant="contained" color="primary" size="large">
|
||||||
|
No, Keep Me In The Game!
|
||||||
|
</Button>
|
||||||
|
<Button onClick={confirmLeaveLobby} variant="outlined" color="error" size="large">
|
||||||
|
Yes, I'm Super Sure - Leave Forever
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Invite Confirmation Dialog */}
|
||||||
|
<Dialog open={inviteConfirmOpen} onClose={handleDeclineInvite} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle sx={{ color: 'primary.main', fontWeight: 'bold' }}>
|
||||||
|
🎮 Join Game Invitation
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ textAlign: 'center', py: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
You've been invited to join "{lobby?.name}"
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||||
|
Would you like to join this lobby and start playing?
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ textAlign: 'left', mb: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
• 🎯 Players: {lobby?.players.filter(p => !p.left).length}/10
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
• 👤 Owner: {lobby?.owner}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
• 🔄 Current turn: {lobby?.players[lobby.turn]?.username || 'Loading...'}
|
||||||
|
</Typography>
|
||||||
|
{lobby?.password && (
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
• 🔒 Password protected
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{lobby?.password && (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Lobby Password"
|
||||||
|
type="password"
|
||||||
|
id="invite-password"
|
||||||
|
margin="normal"
|
||||||
|
placeholder="Enter lobby password"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleDeclineInvite} variant="outlined" color="secondary" size="large">
|
||||||
|
No Thanks, Maybe Later
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const passwordField = document.getElementById('invite-password') as HTMLInputElement
|
||||||
|
const password = passwordField?.value || undefined
|
||||||
|
handleJoinFromInvite(password)
|
||||||
|
}}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
Yes, Join the Game!
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Snackbar for notifications */}
|
||||||
|
<Snackbar
|
||||||
|
open={snackbar.open}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||||
|
message={snackbar.message}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LobbyPage
|
||||||
|
|
||||||
63
frontend/src/components/pages/NotFoundPage.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Home, Error } from '@mui/icons-material'
|
||||||
|
import AppNavigation from '../AppNavigation'
|
||||||
|
|
||||||
|
const NotFoundPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppNavigation />
|
||||||
|
<Container maxWidth="md" sx={{ py: 8 }}>
|
||||||
|
<Card elevation={3}>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 6 }}>
|
||||||
|
<Error color="error" sx={{ fontSize: 120, mb: 3 }} />
|
||||||
|
|
||||||
|
<Typography variant="h1" gutterBottom color="error">
|
||||||
|
404
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Page Not Found
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 4, maxWidth: 600, mx: 'auto' }}>
|
||||||
|
Oops! The page you're looking for doesn't exist. It might have been moved, deleted,
|
||||||
|
or you entered the wrong URL. Don't worry, it happens to the best of us!
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
startIcon={<Home />}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
>
|
||||||
|
Go Home
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="large"
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
>
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotFoundPage
|
||||||
350
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
username: string
|
||||||
|
isSuper?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null
|
||||||
|
notificationsEnabled: boolean
|
||||||
|
pushSubscribed: boolean
|
||||||
|
login: (username: string, password: string) => Promise<boolean>
|
||||||
|
register: (username: string, password: string) => Promise<boolean>
|
||||||
|
logout: () => void
|
||||||
|
isLoading: boolean
|
||||||
|
requestNotificationPermission: () => Promise<boolean>
|
||||||
|
subscribeToPushNotifications: () => Promise<boolean>
|
||||||
|
unsubscribeFromPushNotifications: () => Promise<boolean>
|
||||||
|
checkAndClearPendingInvite: () => string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [notificationsEnabled, setNotificationsEnabled] = useState(false)
|
||||||
|
const [pushSubscribed, setPushSubscribed] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if user is logged in on app start
|
||||||
|
const savedUser = localStorage.getItem('user')
|
||||||
|
if (savedUser) {
|
||||||
|
const userData = JSON.parse(savedUser)
|
||||||
|
setUser(userData)
|
||||||
|
// Check if notifications are already enabled
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
setNotificationsEnabled(true)
|
||||||
|
// Ensure push subscription exists and is registered with backend
|
||||||
|
ensurePushSubscription(userData.username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const requestNotificationPermission = async (): Promise<boolean> => {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
console.warn('This browser does not support notifications.')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const permission = await Notification.requestPermission()
|
||||||
|
if (permission === 'granted') {
|
||||||
|
setNotificationsEnabled(true)
|
||||||
|
console.log('Notifications enabled successfully')
|
||||||
|
|
||||||
|
// Automatically subscribe to push notifications when permission is granted
|
||||||
|
if (user && !pushSubscribed) {
|
||||||
|
console.log('Auto-subscribing to push notifications...')
|
||||||
|
setTimeout(async () => {
|
||||||
|
const success = await subscribeToPushNotifications()
|
||||||
|
if (success) {
|
||||||
|
console.log('✅ Automatically subscribed to push notifications!')
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ Failed to auto-subscribe to push notifications')
|
||||||
|
}
|
||||||
|
}, 500) // Small delay to ensure service worker is ready
|
||||||
|
} else if (user && pushSubscribed) {
|
||||||
|
console.log('✅ User is already subscribed to push notifications')
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
console.warn('Notification permission denied.')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error requesting notification permission:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to re-register push subscription with backend if browser has one
|
||||||
|
const reRegisterPushSubscription = async (username: string) => {
|
||||||
|
if (!username || !('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
const existingSubscription = await registration.pushManager.getSubscription()
|
||||||
|
if (existingSubscription) {
|
||||||
|
// Send subscription to backend (idempotent)
|
||||||
|
await axios.post('http://localhost:3001/api/push/subscribe', {
|
||||||
|
username,
|
||||||
|
subscription: existingSubscription.toJSON()
|
||||||
|
})
|
||||||
|
setPushSubscribed(true)
|
||||||
|
console.log('Re-registered push subscription with backend')
|
||||||
|
} else {
|
||||||
|
setPushSubscribed(false)
|
||||||
|
console.log('No existing push subscription in browser')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setPushSubscribed(false)
|
||||||
|
console.error('Failed to re-register push subscription:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to ensure push subscription exists and is registered with backend
|
||||||
|
const ensurePushSubscription = async (username: string) => {
|
||||||
|
if (!username || !('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
let subscription = await registration.pushManager.getSubscription()
|
||||||
|
if (!subscription) {
|
||||||
|
// No subscription in browser, create one
|
||||||
|
// Get VAPID public key from server
|
||||||
|
const publicKeyResponse = await axios.get('http://localhost:3001/api/push/vapid-public-key')
|
||||||
|
const publicKey = publicKeyResponse.data.publicKey
|
||||||
|
subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
||||||
|
})
|
||||||
|
console.log('Created new push subscription in browser')
|
||||||
|
}
|
||||||
|
// Register (or re-register) with backend
|
||||||
|
await axios.post('http://localhost:3001/api/push/subscribe', {
|
||||||
|
username,
|
||||||
|
subscription: subscription.toJSON()
|
||||||
|
})
|
||||||
|
setPushSubscribed(true)
|
||||||
|
console.log('Ensured push subscription is registered with backend')
|
||||||
|
} catch (error) {
|
||||||
|
setPushSubscribed(false)
|
||||||
|
console.error('Failed to ensure push subscription:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (username: string, password: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const response = await axios.post('http://localhost:3001/api/users/login', { username, password })
|
||||||
|
const userData = response.data // This now includes { username, isSuper }
|
||||||
|
setUser(userData)
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData))
|
||||||
|
|
||||||
|
// Check existing subscription status and request notification permission
|
||||||
|
setTimeout(async () => {
|
||||||
|
// Ensure push subscription exists and is registered with backend
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
setNotificationsEnabled(true)
|
||||||
|
await ensurePushSubscription(userData.username)
|
||||||
|
}
|
||||||
|
await checkExistingPushSubscription(userData.username)
|
||||||
|
// Request notification permission (will auto-subscribe if granted)
|
||||||
|
await requestNotificationPermission()
|
||||||
|
}, 500) // Small delay to let UI settle
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const register = async (username: string, password: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const response = await axios.post('http://localhost:3001/api/users/register', { username, password })
|
||||||
|
const userData = response.data // This now includes { username, isSuper }
|
||||||
|
setUser(userData)
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData))
|
||||||
|
|
||||||
|
// Check existing subscription status and request notification permission
|
||||||
|
setTimeout(async () => {
|
||||||
|
// Ensure push subscription exists and is registered with backend
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
setNotificationsEnabled(true)
|
||||||
|
await ensurePushSubscription(userData.username)
|
||||||
|
}
|
||||||
|
await checkExistingPushSubscription(userData.username)
|
||||||
|
// Request notification permission (will auto-subscribe if granted)
|
||||||
|
await requestNotificationPermission()
|
||||||
|
}, 500) // Small delay to let UI settle
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration failed:', error)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setUser(null)
|
||||||
|
setNotificationsEnabled(false)
|
||||||
|
setPushSubscribed(false)
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
|
||||||
|
// // Attempt to unsubscribe from push notifications (don't wait for result)
|
||||||
|
// unsubscribeFromPushNotifications().catch(error => {
|
||||||
|
// console.warn('Failed to unsubscribe from push notifications during logout:', error)
|
||||||
|
// })
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAndClearPendingInvite = (): string | null => {
|
||||||
|
const pendingInvite = localStorage.getItem('pendingInvite')
|
||||||
|
if (pendingInvite) {
|
||||||
|
localStorage.removeItem('pendingInvite')
|
||||||
|
return pendingInvite
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribeToPushNotifications = async (): Promise<boolean> => {
|
||||||
|
if (!user || !('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
|
console.warn('Push notifications not supported')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get service worker registration
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
|
||||||
|
// Get VAPID public key from server
|
||||||
|
const vapidResponse = await axios.get('http://localhost:3001/api/push/vapid-public-key')
|
||||||
|
const publicKey = vapidResponse.data.publicKey
|
||||||
|
|
||||||
|
// Subscribe to push notifications
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send subscription to server
|
||||||
|
await axios.post('http://localhost:3001/api/push/subscribe', {
|
||||||
|
username: user.username,
|
||||||
|
subscription: subscription.toJSON()
|
||||||
|
})
|
||||||
|
|
||||||
|
setPushSubscribed(true)
|
||||||
|
console.log('Successfully subscribed to push notifications')
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to subscribe to push notifications:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribeFromPushNotifications = async (): Promise<boolean> => {
|
||||||
|
if (!user || !('serviceWorker' in navigator)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get service worker registration
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
const subscription = await registration.pushManager.getSubscription()
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
// Unsubscribe from browser
|
||||||
|
await subscription.unsubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove subscription from server
|
||||||
|
await axios.post('http://localhost:3001/api/push/unsubscribe', {
|
||||||
|
username: user.username
|
||||||
|
})
|
||||||
|
|
||||||
|
setPushSubscribed(false)
|
||||||
|
console.log('Successfully unsubscribed from push notifications')
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to unsubscribe from push notifications:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already has a push subscription
|
||||||
|
const checkExistingPushSubscription = async (username: string) => {
|
||||||
|
if (!username || !('serviceWorker' in navigator)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check with server if user is subscribed
|
||||||
|
const response = await axios.get(`http://localhost:3001/api/push/status/${username}`)
|
||||||
|
if (response.data.subscribed) {
|
||||||
|
setPushSubscribed(true)
|
||||||
|
console.log(`User ${username} has existing push subscription`)
|
||||||
|
} else {
|
||||||
|
setPushSubscribed(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check existing push subscription:', error)
|
||||||
|
setPushSubscribed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to convert VAPID key
|
||||||
|
const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
|
||||||
|
const padding = '='.repeat((4 - base64String.length % 4) % 4)
|
||||||
|
const base64 = (base64String + padding)
|
||||||
|
.replace(/-/g, '+')
|
||||||
|
.replace(/_/g, '/')
|
||||||
|
|
||||||
|
const rawData = window.atob(base64)
|
||||||
|
const outputArray = new Uint8Array(rawData.length)
|
||||||
|
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return outputArray
|
||||||
|
}
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
notificationsEnabled,
|
||||||
|
pushSubscribed,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
isLoading,
|
||||||
|
requestNotificationPermission,
|
||||||
|
subscribeToPushNotifications,
|
||||||
|
unsubscribeFromPushNotifications,
|
||||||
|
checkAndClearPendingInvite,
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
13
frontend/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
31
frontend/src/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
import reportWebVitals from './reportWebVitals';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(
|
||||||
|
document.getElementById('root') as HTMLElement
|
||||||
|
);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
|
// If you want to start measuring performance in your app, pass a function
|
||||||
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||||
|
reportWebVitals();
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/service-worker.js')
|
||||||
|
.then(registration => {
|
||||||
|
console.log('Service Worker registered: ', registration);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Service Worker registration failed: ', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
1
frontend/src/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="react-scripts" />
|
||||||
15
frontend/src/reportWebVitals.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ReportHandler } from 'web-vitals';
|
||||||
|
|
||||||
|
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||||
|
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||||
|
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||||
|
getCLS(onPerfEntry);
|
||||||
|
getFID(onPerfEntry);
|
||||||
|
getFCP(onPerfEntry);
|
||||||
|
getLCP(onPerfEntry);
|
||||||
|
getTTFB(onPerfEntry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reportWebVitals;
|
||||||
22
frontend/src/setupTests.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||||
|
// allows you to do things like:
|
||||||
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// Mock axios globally
|
||||||
|
jest.mock('axios', () => ({
|
||||||
|
post: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
get: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
put: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
delete: jest.fn(() => Promise.resolve({ data: {} }))
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock react-router-dom
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useNavigate: () => jest.fn(),
|
||||||
|
useParams: () => ({}),
|
||||||
|
BrowserRouter: ({ children }: any) => React.createElement('div', null, children)
|
||||||
|
}));
|
||||||
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||