This commit is contained in:
AtHeartEngineer
2025-07-21 23:02:42 -04:00
commit da0e03e287
67 changed files with 44331 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
npm-debug.log
build
dist
.env
.git
.gitignore

14
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

53
backend/package.json Normal file
View 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__/**"
]
}
}

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

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

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

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

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

File diff suppressed because it is too large Load Diff

11
docker-compose.yml Normal file
View 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
View 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
View 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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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

File diff suppressed because it is too large Load Diff

56
frontend/package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

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

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

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

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

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

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

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

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

View 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

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

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

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

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

View 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

View 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

View 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

View 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

View 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

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

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

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

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

BIN
logo.afdesign Normal file

Binary file not shown.

BIN
logo.afdesign~lock~ Normal file

Binary file not shown.

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB