diff --git a/Transcendence/Transcendance-Test.zip b/Transcendence/Transcendance-Test.zip
new file mode 100644
index 0000000..766bda1
Binary files /dev/null and b/Transcendence/Transcendance-Test.zip differ
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/db.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/db.js
new file mode 100755
index 0000000..afc4f32
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/db.js
@@ -0,0 +1,184 @@
+import 'dotenv/config';
+import { Pool } from 'pg';
+
+const pool = new Pool
+({
+ user: process.env.POSTGRES_USER,
+ host: process.env.POSTGRES_HOST,
+ database: process.env.POSTGRES_DB,
+ password: process.env.POSTGRES_PASSWORD,
+ port: 5432,
+});
+
+async function waitForDb(retries = 10, delay = 2000)
+{
+ for (let i = 0; i < retries; i++)
+ {
+ try
+ {
+ await pool.query('SELECT 1');
+ console.log('Database is ready!');
+ return ;
+ }
+ catch (err)
+ {
+ await new Promise(r => setTimeout(r, delay));
+ }
+ }
+ throw new Error('Could not connect to database after multiple attempts');
+}
+
+async function runMigrations()
+{
+ try
+ {
+ // Add total_points column if it doesn't exist
+ await pool.query(`
+ DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='total_points') THEN
+ ALTER TABLE users ADD COLUMN total_points INT DEFAULT 0;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='games_played') THEN
+ ALTER TABLE users ADD COLUMN games_played INT DEFAULT 0;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='games_won') THEN
+ ALTER TABLE users ADD COLUMN games_won INT DEFAULT 0;
+ END IF;
+ END $$;
+ `);
+ console.log('Migrations completed!');
+ }
+ catch (err)
+ {
+ console.error('Error running migrations:', err);
+ }
+}
+
+async function createTables()
+{
+ try
+ {
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ username VARCHAR(50) UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ email VARCHAR(100),
+ avatar_url TEXT DEFAULT '/avatar/default.png',
+ total_points INT DEFAULT 0,
+ games_played INT DEFAULT 0,
+ games_won INT DEFAULT 0,
+ created_at TIMESTAMP DEFAULT NOW()
+ );
+
+ CREATE TABLE IF NOT EXISTS messages(
+ id SERIAL PRIMARY KEY,
+ sender_id INT REFERENCES users(id),
+ received_id INT REFERENCES users(id),
+ content TEXT,
+ created_at TIMESTAMP DEFAULT NOW()
+ );
+
+ CREATE TABLE IF NOT EXISTS friendship (
+ id_user1 INT NOT NULL,
+ id_user2 INT NOT NULL,
+ status VARCHAR(20) NOT NULL,
+ created_at TIMESTAMP DEFAULT NOW(),
+ CHECK (id_user1 < id_user2),
+ PRIMARY KEY (id_user1, id_user2),
+ FOREIGN KEY (id_user1) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY (id_user2) REFERENCES users(id) ON DELETE CASCADE
+ );
+
+ CREATE TABLE IF NOT EXISTS oauth_clients (
+ id SERIAL PRIMARY KEY,
+ provider VARCHAR(50) NOT NULL,
+ client_id VARCHAR(200) NOT NULL,
+ client_secret TEXT,
+ redirect_uri VARCHAR(255),
+ created_at TIMESTAMP DEFAULT NOW(),
+ UNIQUE(provider, client_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS game_rooms (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(100) NOT NULL,
+ status VARCHAR(20) DEFAULT 'waiting',
+ max_players INT DEFAULT 8,
+ current_round INT DEFAULT 0,
+ max_rounds INT DEFAULT 3,
+ round_duration INT DEFAULT 90,
+ created_at TIMESTAMP DEFAULT NOW(),
+ started_at TIMESTAMP,
+ ended_at TIMESTAMP
+ );
+
+ CREATE TABLE IF NOT EXISTS game_players (
+ id SERIAL PRIMARY KEY,
+ room_id INT REFERENCES game_rooms(id) ON DELETE CASCADE,
+ user_id INT REFERENCES users(id) ON DELETE CASCADE,
+ score INT DEFAULT 0,
+ is_drawing BOOLEAN DEFAULT FALSE,
+ joined_at TIMESTAMP DEFAULT NOW(),
+ UNIQUE(room_id, user_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS words (
+ id SERIAL PRIMARY KEY,
+ word VARCHAR(50) NOT NULL UNIQUE
+ );
+
+ CREATE TABLE IF NOT EXISTS game_rounds (
+ id SERIAL PRIMARY KEY,
+ room_id INT REFERENCES game_rooms(id) ON DELETE CASCADE,
+ round_number INT NOT NULL,
+ word_id INT REFERENCES words(id),
+ drawer_id INT REFERENCES users(id),
+ started_at TIMESTAMP DEFAULT NOW(),
+ ended_at TIMESTAMP
+ );
+ `);
+ console.log('Tables created!');
+ }
+ catch (err)
+ {
+ console.error('Error creating tables:', err);
+ }
+}
+
+async function query(text, params)
+{
+ return (pool.query(text, params));
+}
+
+async function ensureOauthClient(provider, client_id, client_secret, redirect_uri)
+{
+ try
+ {
+ const res = await pool.query(
+ `SELECT id FROM oauth_clients WHERE provider = $1 AND client_id = $2`, [provider, client_id]
+ );
+ if (res.rows.length > 0)
+ return res.rows[0];
+ const insert = await pool.query(
+ `INSERT INTO oauth_clients (provider, client_id, client_secret, redirect_uri) VALUES ($1, $2, $3, $4) RETURNING id`,
+ [provider, client_id, client_secret, redirect_uri]
+ );
+ return insert.rows[0];
+ }
+ catch (err)
+ {
+ console.error('Error ensuring oauth client:', err);
+ throw err;
+ }
+}
+
+export
+{
+ waitForDb,
+ createTables,
+ runMigrations,
+ query,
+ ensureOauthClient
+};
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/dockerfile b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/dockerfile
new file mode 100755
index 0000000..b660389
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/dockerfile
@@ -0,0 +1,15 @@
+FROM node:20-alpine
+
+WORKDIR /app
+
+COPY package*.json ./
+
+RUN npm install
+
+COPY . .
+
+EXPOSE 3001
+
+ENV NODE_ENV=development
+
+CMD ["node", "index.js"]
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/index.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/index.js
new file mode 100755
index 0000000..c09c61b
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/index.js
@@ -0,0 +1,59 @@
+import express from 'express';
+import http from 'http';
+import cors from 'cors';
+import {Server} from 'socket.io';
+import authRouter from './routes/auth.js';
+import chatRouter from './routes/global_chat.js';
+import gameRoomRouter from './routes/game_room.js';
+import avatarRouter from './routes/avatar.js';
+import friendsRouter from './routes/friends.js';
+import playerStatsRouter from './routes/player_stats.js';
+import {waitForDb, createTables, runMigrations, ensureOauthClient} from './db.js';
+import setupSocketIO from './services/socket.js';
+import avatarService from './services/avatar.js';
+
+const app = express();
+const server = http.createServer(app);
+const io = new Server(server,
+{
+ cors:
+ {
+ origin: "*",
+ methods: ["GET", "POST"]
+ }
+});
+
+app.use(cors());
+app.use(express.json());
+
+setupSocketIO(io);
+
+async function startServer()
+{
+ await waitForDb();
+ await createTables();
+ await runMigrations();
+
+ // Ensure GitHub OAuth client is registered in DB
+ try {
+ await ensureOauthClient('github', process.env.GITHUB_CLIENT_ID, process.env.GITHUB_CLIENT_SECRET, process.env.GITHUB_CALLBACK_URL || process.env.GITHUB_REDIRECT_URI);
+ } catch (e) {
+ console.warn('OAuth client might already exist or failed to register:', e.message);
+ }
+
+ app.use('/avatar', express.static(avatarService.AVATAR_DIR));
+ app.use('/api/auth', authRouter);
+ app.use('/api/global_chat', chatRouter);
+ app.use('/api/rooms', gameRoomRouter);
+ app.use('/api/avatar', avatarRouter);
+ app.use('/api/friends', friendsRouter);
+ app.use('/api/stats', playerStatsRouter);
+ app.get('/api', (req, res) => res.send('Backend running'));
+
+ server.listen(3001, () =>
+ {
+ console.log('Server ready and listening');
+ });
+}
+
+startServer();
\ No newline at end of file
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/package.json b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/package.json
new file mode 100755
index 0000000..a83699d
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/package.json
@@ -0,0 +1,18 @@
+{
+ "type": "module",
+ "dependencies":
+ {
+ "express": "^4.18.2",
+ "pg": "^8.11.3",
+ "bcrypt": "^5.1.0",
+ "jsonwebtoken": "^9.0.2",
+ "dotenv": "^17.2.3",
+ "socket.io": "^4.6.1",
+ "cors": "^2.8.5",
+ "passport": "0.7.0",
+ "passport-github2": "0.1.12",
+ "express-session": "1.18.0",
+ "multer": "^1.4.5-lts.1",
+ "file-type": "^19.0.0"
+ }
+}
\ No newline at end of file
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/auth.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/auth.js
new file mode 100755
index 0000000..f80ff21
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/auth.js
@@ -0,0 +1,88 @@
+import express from 'express';
+import authService from '../services/auth.js';
+import fetch from 'node-fetch';
+import bcrypt from 'bcrypt';
+import jwt from 'jsonwebtoken';
+import {query} from '../db.js';
+import crypto from 'crypto';
+
+const router = express.Router();
+
+router.post('/register', async(req, res) =>
+{
+ const {username, password} = req.body;
+ if (!username || !password)
+ return (res.status(400).json({error: 'Missing fields'}));
+
+ const result = await authService.register(username, password);
+ res.status(result.status).json(result.data);
+});
+
+router.post('/login', async(req, res) =>
+{
+ console.log("received login!");
+ const {username, password} = req.body;
+ const result = await authService.login(username, password);
+ res.status(result.status).json(result.data);
+});
+
+router.get('/github', (req, res) => {
+ const githubAuthUrl = `https://github.com/login/oauth/authorize?` +
+ `client_id=${process.env.GITHUB_CLIENT_ID}&` +
+ `redirect_uri=${encodeURIComponent(process.env.GITHUB_CALLBACK_URL || process.env.GITHUB_REDIRECT_URI)}&` +
+ `scope=user:email`;
+
+ res.redirect(githubAuthUrl);
+});
+
+router.get('/github/callback', async (req, res) => {
+ const code = req.query.code;
+ if (!code) {
+ return res.status(400).send('Missing code');
+ }
+ try {
+ const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
+ method: 'POST',
+ headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ client_id: process.env.GITHUB_CLIENT_ID,
+ client_secret: process.env.GITHUB_CLIENT_SECRET,
+ code: code
+ })
+ });
+ const tokenData = await tokenResponse.json();
+ const accessToken = tokenData.access_token;
+ if (!accessToken) throw new Error('No access token');
+
+
+ const userResponse = await fetch('https://api.github.com/user', {
+ headers: { 'Authorization': `Bearer ${accessToken}`, 'User-Agent': 'Transcendence' }
+ });
+ const ghUser = await userResponse.json();
+ const ghUsername = ghUser.login || `github_${ghUser.id}`;
+
+
+ let result = await query(`SELECT id FROM users WHERE username = $1`, [ghUsername]);
+ let userId;
+ if (result.rows.length > 0) {
+ userId = result.rows[0].id;
+ } else {
+ const randomPwd = crypto.randomBytes(16).toString('hex');
+ const passwordHash = await bcrypt.hash(randomPwd, 10);
+ await query(`INSERT INTO users (username, password_hash) VALUES ($1, $2)`, [ghUsername, passwordHash]);
+ const inserted = await query(`SELECT id FROM users WHERE username = $1`, [ghUsername]);
+ userId = inserted.rows[0].id;
+ }
+
+ // Issue JWT
+ const token = jwt.sign({ userId: userId, username: ghUsername }, process.env.JWT_SECRET, { expiresIn: '1h' });
+
+ // Send token to opener window and close popup
+ res.send(`
`);
+ } catch (err) {
+ console.error(err);
+ res.status(500).send('GitHub OAuth error');
+ }
+});
+
+export default router;
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/avatar.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/avatar.js
new file mode 100755
index 0000000..117f5e1
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/avatar.js
@@ -0,0 +1,51 @@
+import express from 'express';
+import multer from 'multer';
+import avatarService from '../services/avatar.js';
+import authenticateToken from '../middleware/auth.js';
+
+const router = express.Router();
+
+// Configue multer to use RAM
+const storage = multer.memoryStorage();
+const upload = multer
+({
+ storage: storage,
+ limits:
+ {
+ fileSize: 5 * 1024 * 1024 // 5mb
+ }
+});
+
+router.post('/upload', authenticateToken, upload.single('avatar'), async(req, res) =>
+{
+ if (!req.file)
+ return res.status(400).json({ error: 'No file uploaded' });
+
+ const result = await avatarService.uploadAvatar(req.user.userId, req.file);
+ res.status(result.status).json(result.data);
+});
+
+router.delete('/', authenticateToken, async(req, res) =>
+{
+ const result = await avatarService.deleteAvatar(req.user.userId);
+ res.status(result.status).json(result.data);
+});
+
+router.get('/me', authenticateToken, async(req, res) =>
+{
+ console.log('GET /me hit, user:', req.user);
+ const result = await avatarService.getAvatarUrl(req.user.userId);
+ res.status(result.status).json(result.data);
+});
+
+router.get('/user/:userId', async(req, res) =>
+{
+ const userId = parseInt(req.params.userId);
+ if (isNaN(userId))
+ return res.status(400).json({ error: 'Invalid user ID' });
+
+ const result = await avatarService.getAvatarUrl(userId);
+ res.status(result.status).json(result.data);
+});
+
+export default router;
\ No newline at end of file
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/friends.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/friends.js
new file mode 100755
index 0000000..0722388
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/friends.js
@@ -0,0 +1,69 @@
+import express from 'express';
+import friendsService from '../services/friends.js';
+import authenticateToken from '../middleware/auth.js';
+
+const router = express.Router();
+
+// Get friends list
+router.get('/', authenticateToken, async (req, res) => {
+ const result = await friendsService.getFriends(req.user.userId);
+ res.status(result.status).json(result.data);
+});
+
+// Get pending friend requests
+router.get('/requests', authenticateToken, async (req, res) => {
+ const result = await friendsService.getPendingRequests(req.user.userId);
+ res.status(result.status).json(result.data);
+});
+
+// Search users
+router.get('/search', authenticateToken, async (req, res) => {
+ const { q } = req.query;
+ if (!q || q.trim().length === 0) {
+ return res.status(400).json({ error: 'Search query required' });
+ }
+ const result = await friendsService.searchUsers(req.user.userId, q.trim());
+ res.status(result.status).json(result.data);
+});
+
+// Send friend request
+router.post('/request/:userId', authenticateToken, async (req, res) => {
+ const toUserId = parseInt(req.params.userId);
+ if (isNaN(toUserId)) {
+ return res.status(400).json({ error: 'Invalid user ID' });
+ }
+ const result = await friendsService.sendFriendRequest(req.user.userId, toUserId);
+ res.status(result.status).json(result.data);
+});
+
+// Accept friend request
+router.post('/accept/:userId', authenticateToken, async (req, res) => {
+ const fromUserId = parseInt(req.params.userId);
+ if (isNaN(fromUserId)) {
+ return res.status(400).json({ error: 'Invalid user ID' });
+ }
+ const result = await friendsService.acceptFriendRequest(req.user.userId, fromUserId);
+ res.status(result.status).json(result.data);
+});
+
+// Decline friend request
+router.post('/decline/:userId', authenticateToken, async (req, res) => {
+ const fromUserId = parseInt(req.params.userId);
+ if (isNaN(fromUserId)) {
+ return res.status(400).json({ error: 'Invalid user ID' });
+ }
+ const result = await friendsService.declineFriendRequest(req.user.userId, fromUserId);
+ res.status(result.status).json(result.data);
+});
+
+// Remove friend
+router.delete('/:userId', authenticateToken, async (req, res) => {
+ const friendId = parseInt(req.params.userId);
+ if (isNaN(friendId)) {
+ return res.status(400).json({ error: 'Invalid user ID' });
+ }
+ const result = await friendsService.removeFriend(req.user.userId, friendId);
+ res.status(result.status).json(result.data);
+});
+
+export default router;
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/game_room.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/game_room.js
new file mode 100755
index 0000000..b144b71
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/game_room.js
@@ -0,0 +1,185 @@
+import express from 'express';
+import gameRoomService from '../services/game_room.js';
+import authenticateToken from '../middleware/auth.js';
+import { getIO, broadcastRoomsList } from '../services/socket.js';
+const router = express.Router();
+
+router.get('/', authenticateToken, async(req, res) =>
+{
+ try
+ {
+ const rooms = await gameRoomService.listActiveRooms();
+ res.json(rooms);
+ }
+ catch (err)
+ {
+ console.error(err);
+ res.status(500).json({error: 'Server error'});
+ }
+});
+
+// Get list of rooms currently being played (for spectators)
+router.get('/playing', authenticateToken, async(req, res) =>
+{
+ try
+ {
+ const rooms = await gameRoomService.listPlayingRooms();
+ res.json(rooms);
+ }
+ catch (err)
+ {
+ console.error(err);
+ res.status(500).json({error: 'Server error'});
+ }
+});
+
+// IMPORTANT: This route must be before /:roomId to avoid "current" being interpreted as a roomId
+router.get('/current', authenticateToken, async(req, res) =>
+{
+ try
+ {
+ const room = await gameRoomService.getCurrentRoom(req.user.userId);
+ if (!room)
+ return res.status(204).send(); // No content - user is not in any room
+ res.json(room);
+ }
+ catch(err)
+ {
+ console.error(err);
+ res.status(500).json({error: 'Server error'});
+ }
+});
+
+router.get('/:roomId', authenticateToken, async(req, res) =>
+{
+ try
+ {
+ const room = await gameRoomService.getRoomById(req.params.roomId);
+ if (!room)
+ return (res.status(404).json({error: 'Room not found'}));
+ res.json(room);
+ }
+ catch(err)
+ {
+ console.error(err);
+ res.status(500).json({error: 'Server error'});
+ }
+});
+
+router.get('/:roomId/players', authenticateToken, async(req, res) =>
+{
+ try
+ {
+ const players = await gameRoomService.getRoomPlayers(req.params.roomId);
+ res.json(players);
+ }
+ catch(err)
+ {
+ console.error(err);
+ res.status(500).json({error: 'Server error'});
+ }
+});
+
+router.post('/', authenticateToken, async(req, res) =>
+{
+ try
+ {
+ const {name} = req.body;
+ if (!name)
+ return (res.status(400).json({error: 'Room name required'}));
+ const room = await gameRoomService.createRoom(name, req.user.userId);
+
+ // Broadcast updated rooms list to all clients
+ const io = getIO();
+ if (io) {
+ broadcastRoomsList(io);
+ }
+
+ res.status(201).json(room);
+ }
+ catch(err)
+ {
+ console.error(err);
+ res.status(500).json({error: 'Server error'});
+ }
+});
+
+router.post('/:roomId/join', authenticateToken, async(req, res) =>
+{
+ try
+ {
+ const player = await gameRoomService.joinRoom(req.params.roomId, req.user.userId);
+
+ // Broadcast updated rooms list to all clients
+ const io = getIO();
+ if (io) {
+ broadcastRoomsList(io);
+ }
+
+ res.json(player);
+ }
+ catch(err)
+ {
+ console.error(err);
+ if (err.message.includes('full') || err.message.includes('already'))
+ res.status(400).json({error: err.message});
+ else
+ res.status(500).json({error: err.message});
+ }
+});
+
+router.post('/:roomId/leave', authenticateToken, async(req, res) =>
+{
+ try
+ {
+ await gameRoomService.leaveRoom(req.params.roomId, req.user.userId);
+
+ // Broadcast updated rooms list to all clients
+ const io = getIO();
+ if (io) {
+ broadcastRoomsList(io);
+ }
+
+ res.json({message: 'Left room successfully'});
+ }
+ catch(err)
+ {
+ console.error(err);
+ res.status(500).json({error: 'Server error'});
+ }
+});
+
+// Join a room as spectator
+router.post('/:roomId/spectate', authenticateToken, async(req, res) =>
+{
+ try
+ {
+ const room = await gameRoomService.spectateRoom(req.params.roomId, req.user.userId);
+ res.json(room);
+ }
+ catch(err)
+ {
+ console.error(err);
+ if (err.message.includes('not found') || err.message.includes('not in playing') || err.message.includes('already in'))
+ res.status(400).json({error: err.message});
+ else
+ res.status(500).json({error: err.message});
+ }
+});
+
+// Leave spectator mode
+router.post('/:roomId/leave-spectate', authenticateToken, async(req, res) =>
+{
+ try
+ {
+ await gameRoomService.leaveSpectateRoom(req.params.roomId, req.user.userId);
+ res.json({message: 'Left spectator mode successfully'});
+ }
+ catch(err)
+ {
+ console.error(err);
+ res.status(500).json({error: 'Server error'});
+ }
+});
+
+export default router;
\ No newline at end of file
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/global_chat.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/global_chat.js
new file mode 100755
index 0000000..41279c6
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/global_chat.js
@@ -0,0 +1,20 @@
+import express from 'express';
+import chatService from '../services/global_chat.js';
+import authenticateToken from '../middleware/auth.js';
+const router = express.Router();
+
+router.get('/messages', authenticateToken, async(req, res) =>
+{
+ try
+ {
+ const messages = await chatService.getRecentMessages(50);
+ res.json(messages);
+ }
+ catch(err)
+ {
+ console.error(err);
+ res.status(500).json({error: 'Server error'});
+ }
+});
+
+export default router;
\ No newline at end of file
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/player_stats.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/player_stats.js
new file mode 100755
index 0000000..661d715
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/player_stats.js
@@ -0,0 +1,46 @@
+import express from 'express';
+import playerStatsService from '../services/player_stats.js';
+import authenticateToken from '../middleware/auth.js';
+const router = express.Router();
+
+// Get current user's stats
+router.get('/me', authenticateToken, async (req, res) => {
+ try {
+ const stats = await playerStatsService.getStatsByUserId(req.user.userId);
+ if (!stats) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+ res.json(stats);
+ } catch (err) {
+ console.error('Error getting user stats:', err);
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+// Get stats by username
+router.get('/user/:username', authenticateToken, async (req, res) => {
+ try {
+ const stats = await playerStatsService.getStatsByUsername(req.params.username);
+ if (!stats) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+ res.json(stats);
+ } catch (err) {
+ console.error('Error getting user stats:', err);
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+// Get leaderboard
+router.get('/leaderboard', authenticateToken, async (req, res) => {
+ try {
+ const limit = Math.min(parseInt(req.query.limit) || 10, 50);
+ const leaderboard = await playerStatsService.getLeaderboard(limit);
+ res.json(leaderboard);
+ } catch (err) {
+ console.error('Error getting leaderboard:', err);
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+export default router;
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/auth.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/auth.js
new file mode 100755
index 0000000..ca36c25
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/auth.js
@@ -0,0 +1,63 @@
+import bcrypt from 'bcrypt';
+import jwt from 'jsonwebtoken';
+import {query} from '../db.js';
+
+async function login(username, password)
+{
+ try
+ {
+ const result = await query
+ (
+ `SELECT id, password_hash FROM users WHERE username = $1`,
+ [username]
+ );
+ if (result.rows.length === 0)
+ return ({status: 401, data: {error: 'Invalid credentials'}});
+
+ const user = result.rows[0];
+ const match = await bcrypt.compare(password, user.password_hash);
+ if (!match)
+ return ({status: 401, data: {error: 'Invalid credentials'}});
+
+ const token = jwt.sign
+ (
+ {
+ userId: user.id,
+ username: username
+ },
+ process.env.JWT_SECRET,
+ {expiresIn: '1h'}
+ );
+
+ return ({status: 200, data: {token}});
+ }
+ catch (err)
+ {
+ console.error(err);
+ return ({status: 500, data: {error: 'Server error'}});
+ }
+};
+
+async function register(username, password)
+{
+ try
+ {
+ const password_hash = await bcrypt.hash(password, 10);
+ await query
+ (
+ `INSERT INTO users (username, password_hash) VALUES ($1, $2)`,
+ [username, password_hash]
+ );
+ return ({status: 201, data: {message: 'User created'}});
+ }
+ catch (err)
+ {
+ if (err.code === '23505')
+ return ({status: 409, data: {error: 'Username already exists'}});
+
+ console.error(err);
+ return ({status: 500, data: {error: 'Server error'}});
+ }
+};
+
+export default {register, login};
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/avatar.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/avatar.js
new file mode 100755
index 0000000..05d3b66
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/avatar.js
@@ -0,0 +1,148 @@
+import {query} from '../db.js';
+import path from 'path';
+import fs from 'fs';
+import {fileURLToPath} from 'url';
+import {fileTypeFromBuffer} from 'file-type';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const AVATAR_DIR = path.join(__dirname, '../avatar');
+const DEFAULT_AVATAR = '/avatar/default.png';
+const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5mb
+const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
+
+// Verify that the avatar folder already exists
+if (!fs.existsSync(AVATAR_DIR))
+{
+ fs.mkdirSync(AVATAR_DIR, { recursive: true });
+}
+
+async function uploadAvatar(userId, file)
+{
+ try
+ {
+ // File check (type and size)
+ if (!file)
+ return ({status: 400, data: {error: 'No file provided'}});
+
+ if (!ALLOWED_TYPES.includes(file.mimetype))
+ return ({status: 400, data: { error: 'Invalid file type. Only JPEG, PNG, GIF, and WebP allowed'}});
+
+ const fileType = await fileTypeFromBuffer(file.buffer);
+ if (!fileType || !['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(fileType.mime))
+ return ({status: 400, data: {error: 'Invalid file content. File does not match allowed image types'}});
+
+ if (file.size > MAX_FILE_SIZE)
+ return ({status: 400, data: {error: 'File too large. Maximum size is 5MB'}});
+
+
+ const currentAvatar = await getCurrentAvatar(userId);
+ if (currentAvatar === null)
+ return ({status: 404, data: {error: 'User not found'}});
+
+ // Create a unique name for the new avatar to avoid duplicates
+ const fileExt = path.extname(file.originalname);
+ const fileName = `user_${userId}_${Date.now()}${fileExt}`;
+ const avatarPath = `/avatar/${fileName}`;
+
+ // Save the new avatar in the folder
+ const filePath = path.join(AVATAR_DIR, fileName);
+ fs.writeFileSync(filePath, file.buffer);
+
+ await setAvatar(avatarPath, userId);
+
+ deleteNonDefault(currentAvatar);
+ return ({status: 200, data: {avatar_url: avatarPath}});
+ }
+ catch (err)
+ {
+ console.error('Avatar upload error:', err);
+ return { status: 500, data: { error: 'Server error' } };
+ }
+}
+
+async function deleteAvatar(userId) {
+ try
+ {
+ const currentAvatar = await getCurrentAvatar(userId);
+ if (currentAvatar === null)
+ return ({status: 404, data: {error: 'User not found'}});
+
+ // Reset the avatar to the default one
+ await setAvatar(DEFAULT_AVATAR, userId);
+
+ deleteNonDefault(currentAvatar);
+ return ({status: 200, data: {avatar_url: DEFAULT_AVATAR}});
+ }
+ catch (err)
+ {
+ console.error('Avatar delete error:', err);
+ return ({status: 500, data: {error: 'Server error'}});
+ }
+}
+
+async function setAvatar(newAvatar, userId)
+{
+ await query
+ (
+ 'UPDATE users SET avatar_url = $1 WHERE id = $2',
+ [newAvatar, userId]
+ );
+}
+
+async function getCurrentAvatar(userId)
+{
+ const res = await query
+ (
+ 'SELECT avatar_url FROM users WHERE id = $1',
+ [userId]
+ );
+ if (res.rows.length === 0)
+ return (null);
+ return (res.rows[0].avatar_url);
+}
+
+function deleteNonDefault(curAvatar)
+{
+ if (curAvatar && curAvatar !== DEFAULT_AVATAR)
+ {
+ const fileName = path.basename(curAvatar);
+ const filePath = path.join(AVATAR_DIR, fileName);
+
+ if (fs.existsSync(filePath))
+ fs.unlinkSync(filePath);
+ }
+}
+
+async function getAvatarUrl(userId)
+{
+ try
+ {
+ const result = await query
+ (
+ 'SELECT avatar_url FROM users WHERE id = $1',
+ [userId]
+ );
+
+ if (result.rows.length === 0)
+ return ({status: 404, data: {error: 'User not found'}});
+
+ const avatarUrl = result.rows[0].avatar_url || DEFAULT_AVATAR;
+ return ({status: 200, data: {avatar_url: avatarUrl}});
+ }
+ catch (err)
+ {
+ console.error('Get avatar error:', err);
+ return ({status: 500, data: {error: 'Server error'}});
+ }
+}
+
+export default
+{
+ uploadAvatar,
+ deleteAvatar,
+ getAvatarUrl,
+ AVATAR_DIR,
+ DEFAULT_AVATAR
+};
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/friends.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/friends.js
new file mode 100755
index 0000000..f912188
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/friends.js
@@ -0,0 +1,240 @@
+import { query } from '../db.js';
+
+/**
+ * Get list of friends for a user
+ */
+async function getFriends(userId) {
+ try {
+ const result = await query(
+ `SELECT u.id, u.username, u.avatar_url, u.total_points, u.games_played, u.games_won
+ FROM friendship f
+ JOIN users u ON (
+ CASE
+ WHEN f.id_user1 = $1 THEN f.id_user2 = u.id
+ ELSE f.id_user1 = u.id
+ END
+ )
+ WHERE (f.id_user1 = $1 OR f.id_user2 = $1)
+ AND f.status = 'accepted'`,
+ [userId]
+ );
+ return { status: 200, data: { friends: result.rows } };
+ } catch (err) {
+ console.error('Get friends error:', err);
+ return { status: 500, data: { error: 'Server error' } };
+ }
+}
+
+/**
+ * Get pending friend requests received by user
+ */
+async function getPendingRequests(userId) {
+ try {
+ const result = await query(
+ `SELECT u.id, u.username, u.avatar_url, f.created_at
+ FROM friendship f
+ JOIN users u ON (
+ CASE
+ WHEN f.id_user1 = $1 THEN f.id_user2 = u.id
+ ELSE f.id_user1 = u.id
+ END
+ )
+ WHERE (f.id_user1 = $1 OR f.id_user2 = $1)
+ AND f.status = 'pending_' || $1::text`,
+ [userId]
+ );
+ return { status: 200, data: { requests: result.rows } };
+ } catch (err) {
+ console.error('Get pending requests error:', err);
+ return { status: 500, data: { error: 'Server error' } };
+ }
+}
+
+/**
+ * Search users by username
+ */
+async function searchUsers(userId, searchTerm) {
+ try {
+ const result = await query(
+ `SELECT id, username, avatar_url
+ FROM users
+ WHERE username ILIKE $1
+ AND id != $2
+ LIMIT 20`,
+ [`%${searchTerm}%`, userId]
+ );
+ return { status: 200, data: { users: result.rows } };
+ } catch (err) {
+ console.error('Search users error:', err);
+ return { status: 500, data: { error: 'Server error' } };
+ }
+}
+
+/**
+ * Send a friend request
+ */
+async function sendFriendRequest(fromUserId, toUserId) {
+ try {
+ if (fromUserId === toUserId) {
+ return { status: 400, data: { error: 'Cannot add yourself as friend' } };
+ }
+
+ // Check if user exists
+ const userCheck = await query('SELECT id FROM users WHERE id = $1', [toUserId]);
+ if (userCheck.rows.length === 0) {
+ return { status: 404, data: { error: 'User not found' } };
+ }
+
+ // Ensure id_user1 < id_user2 for the constraint
+ const id1 = Math.min(fromUserId, toUserId);
+ const id2 = Math.max(fromUserId, toUserId);
+
+ // Check existing friendship
+ const existing = await query(
+ 'SELECT status FROM friendship WHERE id_user1 = $1 AND id_user2 = $2',
+ [id1, id2]
+ );
+
+ if (existing.rows.length > 0) {
+ const status = existing.rows[0].status;
+ if (status === 'accepted') {
+ return { status: 400, data: { error: 'Already friends' } };
+ }
+ if (status.startsWith('pending_')) {
+ return { status: 400, data: { error: 'Friend request already exists' } };
+ }
+ }
+
+ // Status indicates who needs to accept: pending_
+ const status = `pending_${toUserId}`;
+
+ await query(
+ `INSERT INTO friendship (id_user1, id_user2, status)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (id_user1, id_user2)
+ DO UPDATE SET status = $3`,
+ [id1, id2, status]
+ );
+
+ return { status: 200, data: { message: 'Friend request sent' } };
+ } catch (err) {
+ console.error('Send friend request error:', err);
+ return { status: 500, data: { error: 'Server error' } };
+ }
+}
+
+/**
+ * Accept a friend request
+ */
+async function acceptFriendRequest(userId, fromUserId) {
+ try {
+ const id1 = Math.min(userId, fromUserId);
+ const id2 = Math.max(userId, fromUserId);
+
+ const result = await query(
+ `UPDATE friendship
+ SET status = 'accepted'
+ WHERE id_user1 = $1 AND id_user2 = $2
+ AND status = $3
+ RETURNING *`,
+ [id1, id2, `pending_${userId}`]
+ );
+
+ if (result.rows.length === 0) {
+ return { status: 404, data: { error: 'Friend request not found' } };
+ }
+
+ return { status: 200, data: { message: 'Friend request accepted' } };
+ } catch (err) {
+ console.error('Accept friend request error:', err);
+ return { status: 500, data: { error: 'Server error' } };
+ }
+}
+
+/**
+ * Decline a friend request
+ */
+async function declineFriendRequest(userId, fromUserId) {
+ try {
+ const id1 = Math.min(userId, fromUserId);
+ const id2 = Math.max(userId, fromUserId);
+
+ const result = await query(
+ `DELETE FROM friendship
+ WHERE id_user1 = $1 AND id_user2 = $2
+ AND status = $3
+ RETURNING *`,
+ [id1, id2, `pending_${userId}`]
+ );
+
+ if (result.rows.length === 0) {
+ return { status: 404, data: { error: 'Friend request not found' } };
+ }
+
+ return { status: 200, data: { message: 'Friend request declined' } };
+ } catch (err) {
+ console.error('Decline friend request error:', err);
+ return { status: 500, data: { error: 'Server error' } };
+ }
+}
+
+/**
+ * Get list of friend IDs for a user (for quick lookup)
+ */
+async function getFriendIds(userId) {
+ try {
+ const result = await query(
+ `SELECT
+ CASE
+ WHEN f.id_user1 = $1 THEN f.id_user2
+ ELSE f.id_user1
+ END as friend_id
+ FROM friendship f
+ WHERE (f.id_user1 = $1 OR f.id_user2 = $1)
+ AND f.status = 'accepted'`,
+ [userId]
+ );
+ return result.rows.map(row => row.friend_id);
+ } catch (err) {
+ console.error('Get friend IDs error:', err);
+ return [];
+ }
+}
+
+/**
+ * Remove a friend
+ */
+async function removeFriend(userId, friendId) {
+ try {
+ const id1 = Math.min(userId, friendId);
+ const id2 = Math.max(userId, friendId);
+
+ const result = await query(
+ `DELETE FROM friendship
+ WHERE id_user1 = $1 AND id_user2 = $2
+ AND status = 'accepted'
+ RETURNING *`,
+ [id1, id2]
+ );
+
+ if (result.rows.length === 0) {
+ return { status: 404, data: { error: 'Friendship not found' } };
+ }
+
+ return { status: 200, data: { message: 'Friend removed' } };
+ } catch (err) {
+ console.error('Remove friend error:', err);
+ return { status: 500, data: { error: 'Server error' } };
+ }
+}
+
+export default {
+ getFriends,
+ getFriendIds,
+ getPendingRequests,
+ searchUsers,
+ sendFriendRequest,
+ acceptFriendRequest,
+ declineFriendRequest,
+ removeFriend
+};
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/game_room.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/game_room.js
new file mode 100755
index 0000000..e4afaa3
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/game_room.js
@@ -0,0 +1,254 @@
+import {query} from '../db.js';
+
+// Create the room with name as the only parameter
+// max_players, status and the other variables have their default values defined in db.js
+async function createRoom(name, userId)
+{
+ const result = await query
+ (
+ `INSERT INTO game_rooms (name) VALUES ($1) RETURNING *`,
+ [name]
+ );
+ const room = result.rows[0];
+
+ await query
+ (
+ 'INSERT INTO game_players (room_id, user_id) VALUES ($1, $2)',
+ [room.id, userId]
+ );
+ return (room);
+}
+
+async function getRoomById(roomId)
+{
+ const result = await query
+ (
+ 'SELECT * FROM game_rooms WHERE id = $1',
+ [roomId]
+ );
+ return (result.rows[0]);
+}
+
+// List all the waiting rooms and the player amount in each of them
+async function listActiveRooms()
+{
+ const result = await query
+ (
+ `SELECT r.*, COUNT(p.id) as player_count
+ FROM game_rooms r
+ LEFT JOIN game_players p ON r.id = p.room_id
+ WHERE r.status = 'waiting'
+ GROUP BY r.id
+ ORDER BY player_count DESC, r.created_at DESC`
+ );
+ return (result.rows);
+}
+
+async function listPlayingRooms()
+{
+ const result = await query
+ (
+ `SELECT r.*, COUNT(p.id) as player_count
+ FROM game_rooms r
+ LEFT JOIN game_players p ON r.id = p.room_id
+ WHERE r.status = 'playing'
+ GROUP BY r.id
+ ORDER BY player_count DESC, r.created_at DESC`
+ );
+ return (result.rows);
+}
+
+async function spectateRoom(roomId, userId)
+{
+ const room = await getRoomById(roomId);
+ if (!room)
+ throw new Error('Room not found');
+
+ if (room.status !== 'playing')
+ throw new Error('Room is not in playing status');
+
+ // Check if user is already a player in any active game
+ const playerInGame = await query
+ (
+ `SELECT r.id, r.name, r.status
+ FROM game_rooms r
+ JOIN game_players gp ON r.id = gp.room_id
+ WHERE gp.user_id = $1 AND r.status IN ('waiting', 'playing')
+ LIMIT 1`,
+ [userId]
+ );
+
+ if (playerInGame.rows.length > 0)
+ {
+ const gameRoom = playerInGame.rows[0];
+ if (gameRoom.id === parseInt(roomId))
+ throw new Error('You cannot spectate a game you are playing in');
+ else
+ throw new Error('You are already in an active game');
+ }
+
+ return (room);
+}
+
+async function leaveSpectateRoom(roomId, userId)
+{
+ const playerCount = await query
+ (
+ 'SELECT COUNT(*) FROM game_players WHERE room_id = $1',
+ [roomId]
+ );
+
+ if (parseInt(playerCount.rows[0].count) === 0)
+ {
+ await query
+ (
+ 'DELETE FROM game_rooms WHERE id = $1',
+ [roomId]
+ );
+ }
+}
+
+async function joinRoom(roomId, userId)
+{
+ const room = await getRoomById(roomId);
+ if (!room)
+ throw new Error('Room not found');
+
+ const playerCount = await query
+ (
+ 'SELECT COUNT(*) FROM game_players WHERE room_id = $1',
+ [roomId]
+ );
+
+ if (parseInt(playerCount.rows[0].count) >= 8)
+ throw new Error('Room is full');
+ if (room.status !== 'waiting')
+ throw new Error('Game already started or ended');
+ const result = await query
+ (
+ 'INSERT INTO game_players (room_id, user_id) VALUES ($1, $2) RETURNING *',
+ [roomId, userId]
+ );
+ return (result.rows[0]);
+}
+
+async function leaveRoom(roomId, userId)
+{
+ await query
+ (
+ 'DELETE FROM game_players WHERE room_id = $1 AND user_id = $2',
+ [roomId, userId]
+ );
+
+ const playerCount = await query
+ (
+ 'SELECT COUNT(*) FROM game_players WHERE room_id = $1',
+ [roomId]
+ );
+
+ if (parseInt(playerCount.rows[0].count) === 0)
+ {
+ await query
+ (
+ 'DELETE FROM game_rooms WHERE id = $1',
+ [roomId]
+ );
+ }
+}
+
+// List the players in the room and their scores
+// Useful for the scoreboard and also tell which player is currently drawing
+async function getRoomPlayers(roomId)
+{
+ const result = await query
+ (
+ `SELECT gp.*, u.username, u.avatar_url, u.total_points, u.games_played, u.games_won
+ FROM game_players gp
+ JOIN users u ON gp.user_id = u.id
+ WHERE gp.room_id = $1
+ ORDER BY gp.score DESC`,
+ [roomId]
+ );
+ return (result.rows);
+}
+
+// Get the current room of a user (if any)
+async function getCurrentRoom(userId)
+{
+ const result = await query
+ (
+ `SELECT r.*
+ FROM game_rooms r
+ JOIN game_players gp ON r.id = gp.room_id
+ WHERE gp.user_id = $1 AND r.status IN ('waiting', 'playing')
+ LIMIT 1`,
+ [userId]
+ );
+ return (result.rows[0] || null);
+}
+
+// Update room status (waiting, playing, ended)
+async function updateRoomStatus(roomId, status)
+{
+ const validStatuses = ['waiting', 'playing', 'ended'];
+ if (!validStatuses.includes(status))
+ throw new Error('Invalid status');
+
+ let updateQuery = 'UPDATE game_rooms SET status = $1';
+ const params = [status, roomId];
+
+ if (status === 'playing')
+ {
+ updateQuery += ', started_at = NOW()';
+ }
+ else if (status === 'ended')
+ {
+ updateQuery += ', ended_at = NOW()';
+ }
+
+ updateQuery += ' WHERE id = $2 RETURNING *';
+
+ const result = await query(updateQuery, params);
+ return (result.rows[0]);
+}
+
+async function resetRoomScores(roomId)
+{
+ await query
+ (
+ 'UPDATE game_players SET score = 0 WHERE room_id = $1',
+ [roomId]
+ );
+}
+
+async function cleanupEndedRooms()
+{
+ await query
+ (
+ 'DELETE FROM game_players WHERE room_id IN (SELECT id FROM game_rooms WHERE status = $1)',
+ ['ended']
+ );
+
+ await query
+ (
+ 'DELETE FROM game_rooms WHERE status = $1',
+ ['ended']
+ );
+}
+
+export default
+{
+ createRoom,
+ getRoomById,
+ listActiveRooms,
+ listPlayingRooms,
+ spectateRoom,
+ leaveSpectateRoom,
+ joinRoom,
+ leaveRoom,
+ getRoomPlayers,
+ getCurrentRoom,
+ updateRoomStatus,
+ resetRoomScores,
+ cleanupEndedRooms
+};
\ No newline at end of file
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/github.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/github.js
new file mode 100755
index 0000000..e69de29
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/global_chat.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/global_chat.js
new file mode 100755
index 0000000..16e8065
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/global_chat.js
@@ -0,0 +1,27 @@
+import {query} from '../db.js';
+
+async function saveMessage(userId, content)
+{
+ const result = await query
+ (
+ 'INSERT INTO messages (sender_id, content) VALUES ($1 ,$2) RETURNING *',
+ [userId, content]
+ );
+ return (result.rows[0]);
+}
+
+async function getRecentMessages(limit = 50)
+{
+ const result = await query
+ (
+ `SELECT m.sender_id, m.content, m.created_at, u.username
+ FROM messages m
+ JOIN users u ON m.sender_id = u.id
+ ORDER BY m.created_at DESC
+ LIMIT $1`,
+ [limit]
+ );
+ return (result.rows.reverse());
+}
+
+export default {saveMessage, getRecentMessages};
\ No newline at end of file
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/player_stats.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/player_stats.js
new file mode 100755
index 0000000..41d820a
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/player_stats.js
@@ -0,0 +1,88 @@
+import { query } from '../db.js';
+
+// Get player stats by user ID
+async function getStatsByUserId(userId) {
+ const result = await query(
+ `SELECT id, username, avatar_url, total_points, games_played, games_won, created_at
+ FROM users WHERE id = $1`,
+ [userId]
+ );
+ return result.rows[0] || null;
+}
+
+// Get player stats by username
+async function getStatsByUsername(username) {
+ const result = await query(
+ `SELECT id, username, avatar_url, total_points, games_played, games_won, created_at
+ FROM users WHERE username = $1`,
+ [username]
+ );
+ return result.rows[0] || null;
+}
+
+// Update player points (add points to total)
+async function addPoints(userId, points) {
+ const result = await query(
+ `UPDATE users SET total_points = COALESCE(total_points, 0) + $1 WHERE id = $2 RETURNING total_points`,
+ [points, userId]
+ );
+ return result.rows[0]?.total_points || 0;
+}
+
+// Update player points by username
+async function addPointsByUsername(username, points) {
+ const result = await query(
+ `UPDATE users SET total_points = COALESCE(total_points, 0) + $1 WHERE username = $2 RETURNING total_points`,
+ [points, username]
+ );
+ return result.rows[0]?.total_points || 0;
+}
+
+// Increment games played
+async function incrementGamesPlayed(userId) {
+ await query(
+ `UPDATE users SET games_played = COALESCE(games_played, 0) + 1 WHERE id = $1`,
+ [userId]
+ );
+}
+
+// Increment games won
+async function incrementGamesWon(userId) {
+ await query(
+ `UPDATE users SET games_won = COALESCE(games_won, 0) + 1 WHERE id = $1`,
+ [userId]
+ );
+}
+
+// Get leaderboard (top players by points)
+async function getLeaderboard(limit = 10) {
+ const result = await query(
+ `SELECT id, username, avatar_url, total_points, games_played, games_won
+ FROM users
+ WHERE total_points > 0
+ ORDER BY total_points DESC
+ LIMIT $1`,
+ [limit]
+ );
+ return result.rows;
+}
+
+// Get user ID by username
+async function getUserIdByUsername(username) {
+ const result = await query(
+ `SELECT id FROM users WHERE username = $1`,
+ [username]
+ );
+ return result.rows[0]?.id || null;
+}
+
+export default {
+ getStatsByUserId,
+ getStatsByUsername,
+ addPoints,
+ addPointsByUsername,
+ incrementGamesPlayed,
+ incrementGamesWon,
+ getLeaderboard,
+ getUserIdByUsername
+};
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/socket.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/socket.js
new file mode 100755
index 0000000..5888950
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/socket.js
@@ -0,0 +1,795 @@
+import jwt from 'jsonwebtoken';
+import chatService from './global_chat.js';
+import friendsService from './friends.js';
+import gameRoomService from './game_room.js';
+import playerStatsService from './player_stats.js';
+
+// Store game state per room
+const gameRooms = new Map();
+
+// Store tetris duel rooms { roomCode → Map }
+const tetrisRooms = new Map();
+
+// Store io instance globally for use in routes
+let ioInstance = null;
+
+export function getIO() {
+ return ioInstance;
+}
+
+// Broadcast rooms list to all connected clients
+async function broadcastRoomsList(io) {
+ try {
+ const rooms = await gameRoomService.listActiveRooms();
+ io.emit('game-rooms-updated', { rooms });
+ } catch (err) {
+ console.error('Error broadcasting rooms list:', err);
+ }
+}
+
+// Check if a playing game has only 1 player left and auto-stop it
+async function checkAndStopSinglePlayerGame(io, roomId, dbRoomId) {
+ if (!dbRoomId) return;
+
+ try {
+ // Check if room is in 'playing' status
+ const room = await gameRoomService.getRoomById(dbRoomId);
+ if (!room || room.status !== 'playing') return;
+
+ // Count remaining players
+ const players = await gameRoomService.getRoomPlayers(dbRoomId);
+ if (players.length <= 1) {
+ console.log(`Room ${dbRoomId} has only ${players.length} player(s) left, ending game`);
+
+ // Update room status to 'ended'
+ await gameRoomService.updateRoomStatus(dbRoomId, 'waiting');
+ await gameRoomService.resetRoomScores(dbRoomId);
+
+ // Remove from game state
+ gameRooms.delete(roomId);
+
+ // Notify remaining player(s)
+ io.to(roomId).emit('game-ended');
+ io.to(roomId).emit('game-message', {
+ message: 'La partie s\'est terminée car il ne reste qu\'un seul joueur',
+ type: 'info'
+ });
+
+ // Broadcast updated rooms list
+ broadcastRoomsList(io);
+ }
+ } catch (err) {
+ console.error('Error checking single player game:', err);
+ }
+}
+
+// Save round points to database (only the difference from round start)
+async function saveRoundPoints(currentScores, roundStartScores) {
+ for (const [username, currentPoints] of Object.entries(currentScores)) {
+ const startPoints = roundStartScores[username] || 0;
+ const pointsEarned = currentPoints - startPoints;
+ if (pointsEarned !== 0) {
+ try {
+ await playerStatsService.addPointsByUsername(username, pointsEarned);
+ console.log(`Saved ${pointsEarned} points for ${username}`);
+ } catch (err) {
+ console.error(`Error saving points for ${username}:`, err);
+ }
+ }
+ }
+}
+
+function setupSocketIO(io)
+{
+ ioInstance = io;
+ io.use((socket, next) =>
+ {
+ const token = socket.handshake.auth.token;
+ if (!token)
+ return (next(new Error('Authentication error')));
+
+ try
+ {
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
+ socket.user = decoded;
+ next();
+ }
+ catch(err)
+ {
+ next(new Error('Authentication error'));
+ }
+ });
+
+ io.on('connection', async (socket) =>
+ {
+ console.log(`User connected: ${socket.user.username}`);
+
+ socket.join('general-chat');
+
+ // Send recent messages and friend IDs on connection
+ try {
+ const [recentMessages, friendIds] = await Promise.all([
+ chatService.getRecentMessages(50),
+ friendsService.getFriendIds(socket.user.userId)
+ ]);
+
+ socket.emit('chat-init', {
+ messages: recentMessages,
+ friendIds: friendIds
+ });
+ } catch (err) {
+ console.error('Error fetching initial data:', err);
+ }
+
+ socket.on('chat-message', async(data) =>
+ {
+ try
+ {
+ const message = await chatService.saveMessage(socket.user.userId, data.content);
+ socket.broadcast.to('general-chat').emit('chat-message',
+ {
+ id: message.id,
+ sender_id: socket.user.userId,
+ username: socket.user.username,
+ content: message.content,
+ created_at: message.created_at
+ });
+ }
+ catch (err)
+ {
+ console.error('Error saving message:', err);
+ socket.emit('error', {message: 'Failed to send message'});
+ }
+ });
+
+ // ============================================
+ // GAME ROOM EVENTS
+ // ============================================
+
+ // Join a game room
+ socket.on('game-join-room', async (data) => {
+ console.log('Received game-join-room from', socket.user.username, 'data:', data);
+ const roomId = `game-room-${data.roomId}`;
+ socket.join(roomId);
+ socket.gameRoomId = roomId;
+ socket.gameRoomDbId = data.roomId;
+ console.log(`${socket.user.username} joined ${roomId}, socket.gameRoomId set to:`, socket.gameRoomId);
+
+ // Send confirmation to the socket that joined
+ socket.emit('game-room-joined', {
+ roomId: data.roomId,
+ success: true
+ });
+
+ // Get updated player list from DB
+ try {
+ const players = await gameRoomService.getRoomPlayers(data.roomId);
+ // Notify ALL players in the room (including the one who joined) with updated player list
+ io.to(roomId).emit('game-players-updated', { players });
+ } catch (err) {
+ console.error('Error getting room players:', err);
+ }
+
+ // Notify others in the room that someone joined
+ socket.to(roomId).emit('game-player-joined', {
+ username: socket.user.username,
+ userId: socket.user.userId
+ });
+
+ // Broadcast rooms list update to everyone
+ broadcastRoomsList(io);
+
+ // Send current game state if game is in progress
+ const gameState = gameRooms.get(roomId);
+ if (gameState && gameState.isPlaying) {
+ socket.emit('game-state-sync', {
+ isPlaying: gameState.isPlaying,
+ drawer: gameState.drawer,
+ wordLength: gameState.currentWord ? gameState.currentWord.length : 0,
+ revealedLetters: gameState.revealedLetters,
+ revealedWord: gameState.revealedWord || [],
+ guessedLetters: gameState.guessedLetters,
+ players: gameState.players
+ });
+ }
+ });
+
+ // Leave a game room
+ socket.on('game-leave-room', async () => {
+ if (socket.gameRoomId) {
+ const roomId = socket.gameRoomId;
+ const dbRoomId = socket.gameRoomDbId;
+
+ socket.to(roomId).emit('game-player-left', {
+ username: socket.user.username,
+ userId: socket.user.userId
+ });
+ socket.leave(roomId);
+ console.log(`${socket.user.username} left ${roomId}`);
+
+ // Get updated player list and broadcast to remaining players
+ if (dbRoomId) {
+ try {
+ const players = await gameRoomService.getRoomPlayers(dbRoomId);
+ io.to(roomId).emit('game-players-updated', { players });
+ } catch (err) {
+ // Room may have been deleted
+ console.log('Room may have been deleted:', err.message);
+ }
+ }
+
+ socket.gameRoomId = null;
+ socket.gameRoomDbId = null;
+
+ // Check if game should auto-stop due to single player
+ await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
+ // Broadcast updated rooms list
+ broadcastRoomsList(io);
+ }
+ });
+
+ // Join a game room as spectator
+ socket.on('game-spectate-room', async (data) => {
+ console.log('Received game-spectate-room from', socket.user.username, 'data:', data);
+ const roomId = `game-room-${data.roomId}`;
+
+ // Verify room exists and is in playing status, and user is not already in a game
+ try {
+ const room = await gameRoomService.spectateRoom(data.roomId, socket.user.userId);
+
+ socket.join(roomId);
+ socket.gameRoomId = roomId;
+ socket.gameRoomDbId = data.roomId;
+ socket.isSpectator = true;
+ console.log(`${socket.user.username} joined ${roomId} as spectator`);
+
+ // Send confirmation
+ socket.emit('game-spectate-joined', {
+ roomId: data.roomId,
+ success: true
+ });
+
+ // Notify others that a spectator joined
+ socket.to(roomId).emit('game-spectator-joined', {
+ username: socket.user.username
+ });
+
+ // Send current game state
+ const gameState = gameRooms.get(roomId);
+ if (gameState && gameState.isPlaying) {
+ socket.emit('game-state-sync', {
+ isPlaying: gameState.isPlaying,
+ drawer: gameState.drawer,
+ wordLength: gameState.currentWord ? gameState.currentWord.length : 0,
+ revealedLetters: gameState.revealedLetters,
+ revealedWord: gameState.revealedWord || [],
+ guessedLetters: gameState.guessedLetters,
+ players: gameState.players,
+ scores: gameState.scores || {}
+ });
+ }
+ } catch (err) {
+ console.error('Error joining as spectator:', err);
+ socket.emit('game-spectate-error', {
+ error: err.message || 'Cannot spectate this room'
+ });
+ }
+ });
+
+ // Leave spectator mode
+ socket.on('game-leave-spectate', () => {
+ if (socket.gameRoomId && socket.isSpectator) {
+ const roomId = socket.gameRoomId;
+
+ socket.to(roomId).emit('game-spectator-left', {
+ username: socket.user.username
+ });
+
+ socket.leave(roomId);
+ console.log(`${socket.user.username} left spectator mode in ${roomId}`);
+
+ socket.gameRoomId = null;
+ socket.gameRoomDbId = null;
+ socket.isSpectator = false;
+ }
+ });
+
+
+ // Start the game
+ socket.on('game-start', async (data) => {
+ console.log('Received game-start event from', socket.user.username);
+ console.log('socket.gameRoomId:', socket.gameRoomId);
+
+ // Security check: need at least 2 players
+ if (!data.players || data.players.length < 2) {
+ console.log('Game start rejected: not enough players');
+ socket.emit('game-start-error', {
+ error: 'Il faut au moins 2 joueurs pour commencer'
+ });
+ return;
+ }
+
+ const gameStartedData = {
+ drawer: data.drawer,
+ players: data.players
+ };
+
+ const roomId = socket.gameRoomId;
+
+ // If no roomId, still start the game for this socket only
+ if (!roomId) {
+ console.log('WARNING: No roomId for socket, starting game for this socket only');
+ socket.emit('game-started', gameStartedData);
+ return;
+ }
+
+ // Verify player count from database
+ const dbRoomId = socket.gameRoomDbId;
+ if (dbRoomId) {
+ try {
+ const players = await gameRoomService.getRoomPlayers(dbRoomId);
+ if (players.length < 2) {
+ console.log(`Game start rejected: only ${players.length} player(s) in room`);
+ socket.emit('game-start-error', {
+ error: 'Il faut au moins 2 joueurs pour commencer'
+ });
+ return;
+ }
+ } catch (err) {
+ console.error('Error checking player count:', err);
+ }
+ }
+
+ // Update room status to 'playing' in database
+ if (dbRoomId) {
+ try {
+ await gameRoomService.updateRoomStatus(dbRoomId, 'playing');
+ console.log(`Room ${dbRoomId} status updated to 'playing'`);
+ } catch (err) {
+ console.error('Error updating room status to playing:', err);
+ }
+ }
+
+ // Initialize scores for all players
+ const scores = {};
+ data.players.forEach(p => scores[p] = 0);
+
+ const gameState = {
+ isPlaying: true,
+ currentWord: '',
+ revealedLetters: [],
+ drawer: data.drawer,
+ players: data.players,
+ currentPlayerIndex: 0,
+ guessedLetters: [],
+ scores: scores,
+ roundStartScores: { ...scores }
+ };
+ gameRooms.set(roomId, gameState);
+
+ // Emit to OTHER players in the room
+ socket.to(roomId).emit('game-started', gameStartedData);
+
+ // Emit directly to this socket (the one who started the game)
+ socket.emit('game-started', gameStartedData);
+
+ console.log(`Game started in ${roomId} by ${socket.user.username}`);
+
+ // Broadcast updated rooms list (this room should no longer appear)
+ broadcastRoomsList(io);
+ });
+
+ // Drawer sets the word
+ socket.on('game-set-word', (data) => {
+ const roomId = socket.gameRoomId;
+ if (!roomId) return;
+
+ const gameState = gameRooms.get(roomId);
+ if (!gameState) return;
+
+ gameState.currentWord = data.word.toLowerCase();
+ gameState.revealedLetters = new Array(data.word.length).fill(false);
+ gameState.revealedWord = new Array(data.word.length).fill('_');
+ gameState.guessedLetters = [];
+ gameState.wrongGuesses = 0;
+
+ // Initialize scores if not already done
+ if (!gameState.scores) {
+ gameState.scores = {};
+ gameState.players.forEach(p => gameState.scores[p] = 0);
+ }
+
+ // Notify all players (without revealing the word)
+ io.to(roomId).emit('game-word-set', {
+ wordLength: data.word.length,
+ drawer: socket.user.username,
+ revealedWord: gameState.revealedWord,
+ scores: gameState.scores
+ });
+ });
+
+ // Drawing data (real-time)
+ socket.on('game-draw', (data) => {
+ const roomId = socket.gameRoomId;
+ if (!roomId) return;
+
+ // Spectators cannot draw
+ if (socket.isSpectator) {
+ console.log(`Spectator ${socket.user.username} tried to draw - blocked`);
+ return;
+ }
+
+ // Broadcast drawing to all other players in the room
+ socket.to(roomId).emit('game-draw', {
+ x1: data.x1,
+ y1: data.y1,
+ x2: data.x2,
+ y2: data.y2,
+ color: data.color,
+ lineWidth: data.lineWidth
+ });
+ });
+
+ // Clear canvas
+ socket.on('game-clear-canvas', () => {
+ const roomId = socket.gameRoomId;
+ if (!roomId) return;
+
+ // Spectators cannot clear canvas
+ if (socket.isSpectator) return;
+
+ socket.to(roomId).emit('game-clear-canvas');
+ });
+
+ // Player makes a guess
+ socket.on('game-guess', (data) => {
+ const roomId = socket.gameRoomId;
+ if (!roomId) return;
+
+ // Spectators cannot make guesses
+ if (socket.isSpectator) {
+ console.log(`Spectator ${socket.user.username} tried to guess - blocked`);
+ return;
+ }
+
+ const gameState = gameRooms.get(roomId);
+ if (!gameState || !gameState.currentWord) return;
+
+ const guess = data.guess.toLowerCase();
+ const isLetter = guess.length === 1;
+ let success = false;
+ let points = 0;
+ const username = socket.user.username;
+
+ // Initialize scores if needed
+ if (!gameState.scores) {
+ gameState.scores = {};
+ gameState.players.forEach(p => gameState.scores[p] = 0);
+ }
+ if (!gameState.scores[username]) {
+ gameState.scores[username] = 0;
+ }
+
+ if (isLetter) {
+ // Check if letter was already guessed
+ if (gameState.guessedLetters.includes(guess)) {
+ socket.emit('game-guess-result', {
+ guess,
+ success: false,
+ type: 'letter',
+ message: 'Lettre deja proposee',
+ username: username,
+ scores: gameState.scores
+ });
+ return;
+ }
+
+ gameState.guessedLetters.push(guess);
+
+ // Check each position and reveal the actual letter
+ let lettersFound = 0;
+ for (let i = 0; i < gameState.currentWord.length; i++) {
+ if (gameState.currentWord[i] === guess) {
+ gameState.revealedLetters[i] = true;
+ gameState.revealedWord[i] = guess;
+ success = true;
+ lettersFound++;
+ }
+ }
+
+ // Points: 10 per letter found, -5 for wrong guess
+ if (success) {
+ points = lettersFound * 10;
+ gameState.scores[username] += points;
+ } else {
+ points = -5;
+ gameState.scores[username] += points;
+ gameState.wrongGuesses++;
+ }
+ } else {
+ // Full word guess
+ success = guess === gameState.currentWord;
+ if (success) {
+ gameState.revealedLetters = gameState.revealedLetters.map(() => true);
+ gameState.revealedWord = gameState.currentWord.split('');
+ // Bonus points for guessing the whole word
+ const remainingLetters = gameState.revealedLetters.filter(r => !r).length;
+ points = 50 + (remainingLetters * 5);
+ gameState.scores[username] += points;
+ } else {
+ points = -10;
+ gameState.scores[username] += points;
+ gameState.wrongGuesses++;
+ }
+ }
+
+ // Broadcast result to all players with the revealed word (actual letters)
+ io.to(roomId).emit('game-guess-result', {
+ guess,
+ success,
+ type: isLetter ? 'letter' : 'word',
+ username: username,
+ revealedLetters: gameState.revealedLetters,
+ revealedWord: gameState.revealedWord,
+ points: points,
+ scores: gameState.scores
+ });
+
+ // Check if word is complete
+ if (gameState.revealedLetters.every(r => r)) {
+ // Bonus points for the drawer
+ const drawerBonus = Math.max(0, 30 - (gameState.wrongGuesses * 5));
+ if (gameState.scores[gameState.drawer]) {
+ gameState.scores[gameState.drawer] += drawerBonus;
+ }
+
+ // Save points to database for all players
+ saveRoundPoints(gameState.scores, gameState.roundStartScores || {});
+ // Update round start scores for next round
+ gameState.roundStartScores = { ...gameState.scores };
+
+ io.to(roomId).emit('game-word-found', {
+ word: gameState.currentWord,
+ winner: username,
+ scores: gameState.scores,
+ drawerBonus: drawerBonus
+ });
+ }
+ });
+
+ // Next round
+ socket.on('game-next-round', (data) => {
+ const roomId = socket.gameRoomId;
+ if (!roomId) return;
+
+ const gameState = gameRooms.get(roomId);
+ if (!gameState) return;
+
+ gameState.currentWord = '';
+ gameState.revealedLetters = [];
+ gameState.guessedLetters = [];
+ gameState.drawer = data.drawer;
+
+ io.to(roomId).emit('game-new-round', {
+ drawer: data.drawer
+ });
+ });
+
+ socket.on('leave-room-during-game', async () => {
+ const roomId = socket.gameRoomId;
+ const dbRoomId = socket.gameRoomDbId;
+ const userId = socket.user.userId;
+ const username = socket.user.username;
+
+ if (!roomId || !dbRoomId || !userId) return;
+
+ console.log(`Player ${username} leaving room ${roomId} during game`);
+
+ try
+ {
+ socket.leave(roomId);
+
+ await gameRoomService.leaveRoom(dbRoomId, userId);
+
+ io.to(roomId).emit('game-player-left', {
+ username: username,
+ message: `${username} a quitté la partie`
+ });
+
+ const gameState = gameRooms.get(roomId);
+ if (gameState)
+ {
+ gameState.players = gameState.players.filter(p => p !== username);
+ delete gameState.scores[username];
+
+ io.to(roomId).emit('scores-updated', gameState.scores);
+ }
+
+ await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
+
+ socket.gameRoomId = null;
+ socket.gameRoomDbId = null;
+
+ broadcastRoomsList(io);
+ }
+ catch (err)
+ {
+ console.error('Error leaving room during game:', err);
+ }
+ });
+
+ // End game
+ socket.on('game-end', async () => {
+ const roomId = socket.gameRoomId;
+ if (!roomId) return;
+
+ // Update room status to 'waiting' in database
+ const dbRoomId = socket.gameRoomDbId;
+ if (dbRoomId) {
+ try {
+ await gameRoomService.updateRoomStatus(dbRoomId, 'waiting');
+ await gameRoomService.resetRoomScores(dbRoomId);
+ console.log(`Room ${dbRoomId} status updated to 'waiting'`);
+ } catch (err) {
+ console.error('Error updating room status to waiting:', err);
+ }
+ }
+
+ gameRooms.delete(roomId);
+ io.to(roomId).emit('game-ended');
+
+ // Broadcast updated rooms list
+ broadcastRoomsList(io);
+ });
+
+ // ============================================
+ // TETRIS DUEL EVENTS
+ // ============================================
+
+ socket.on('tetris:join', ({ roomCode }) => {
+ const code = String(roomCode).toUpperCase().slice(0, 8);
+
+ // Quitter l'ancienne room tetris si besoin
+ if (socket.tetrisRoomCode) {
+ _tetrisLeave(socket);
+ }
+
+ if (!tetrisRooms.has(code)) {
+ tetrisRooms.set(code, new Map());
+ }
+ const room = tetrisRooms.get(code);
+
+ if (room.size >= 2) {
+ socket.emit('tetris:room-status', { status: 'full', players: [] });
+ return;
+ }
+
+ room.set(socket.id, socket);
+ socket.tetrisRoomCode = code;
+
+ const players = [...room.values()].map(s => s.user.username);
+
+ if (room.size === 1) {
+ socket.emit('tetris:room-status', { status: 'waiting', players });
+ } else {
+ // Notifier les deux joueurs
+ for (const s of room.values()) {
+ s.emit('tetris:room-status', { status: 'ready', players });
+ }
+ // Notifier l'adversaire qu'un nouveau joueur a rejoint
+ for (const [id, s] of room) {
+ if (id !== socket.id) {
+ s.emit('tetris:opponent-joined', { username: socket.user.username });
+ }
+ }
+ }
+ });
+
+ socket.on('tetris:leave', () => {
+ _tetrisLeave(socket);
+ });
+
+ // Relay pur : grid-update → adversaire uniquement
+ socket.on('tetris:grid-update', (data) => {
+ _tetrisRelayToOpponent(socket, 'tetris:grid-update', data);
+ });
+
+ // Relay pur : lines-cleared → adversaire uniquement
+ socket.on('tetris:lines-cleared', (data) => {
+ _tetrisRelayToOpponent(socket, 'tetris:lines-cleared', data);
+ });
+
+ // start-duel → relayé aux DEUX joueurs de la room (inclut l'émetteur)
+ socket.on('tetris:start-duel', () => {
+ const code = socket.tetrisRoomCode;
+ if (!code) return;
+ const room = tetrisRooms.get(code);
+ if (!room || room.size < 2) return;
+ for (const s of room.values()) {
+ s.emit('tetris:start-duel');
+ }
+ });
+
+ // game-over → relayé en opponent-game-over chez l'adversaire
+ socket.on('tetris:game-over', (data) => {
+ _tetrisRelayToOpponent(socket, 'tetris:opponent-game-over', data);
+ });
+
+ socket.on('disconnect', async () =>
+ {
+ // Nettoyage room tetris
+ if (socket.tetrisRoomCode) {
+ _tetrisLeave(socket);
+ }
+
+ console.log(`User disconnected: ${socket.user.username}`);
+
+ // Notify game room if player/spectator was in one
+ if (socket.gameRoomId) {
+ const roomId = socket.gameRoomId;
+ const dbRoomId = socket.gameRoomDbId;
+
+ // If spectator, just notify and leave
+ if (socket.isSpectator) {
+ socket.to(roomId).emit('game-spectator-left', {
+ username: socket.user.username
+ });
+ console.log(`Spectator ${socket.user.username} disconnected from ${roomId}`);
+ } else {
+ // Regular player disconnect
+ socket.to(roomId).emit('game-player-left', {
+ username: socket.user.username,
+ userId: socket.user.userId
+ });
+
+ // Get updated player list and broadcast
+ if (dbRoomId) {
+ try {
+ const players = await gameRoomService.getRoomPlayers(dbRoomId);
+ io.to(roomId).emit('game-players-updated', { players });
+ } catch (err) {
+ console.log('Room may have been deleted on disconnect:', err.message);
+ }
+ }
+
+ // Check if game should auto-stop due to single player
+ await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
+
+ // Broadcast updated rooms list
+ broadcastRoomsList(io);
+ }
+ }
+ });
+ });
+}
+
+// ── Helpers tetris duel ──────────────────────────────────────────────────
+
+function _tetrisLeave(socket) {
+ const code = socket.tetrisRoomCode;
+ if (!code) return;
+ const room = tetrisRooms.get(code);
+ if (room) {
+ room.delete(socket.id);
+ // Notifier l'adversaire restant
+ for (const s of room.values()) {
+ s.emit('tetris:opponent-left');
+ s.emit('tetris:room-status', { status: 'waiting', players: [s.user.username] });
+ }
+ if (room.size === 0) tetrisRooms.delete(code);
+ }
+ socket.tetrisRoomCode = null;
+}
+
+function _tetrisRelayToOpponent(socket, event, data) {
+ const code = socket.tetrisRoomCode;
+ if (!code) return;
+ const room = tetrisRooms.get(code);
+ if (!room) return;
+ for (const [id, s] of room) {
+ if (id !== socket.id) s.emit(event, data);
+ }
+}
+
+export { broadcastRoomsList };
+export default setupSocketIO;
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/dockerfile b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/dockerfile
new file mode 100755
index 0000000..310e2ed
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/dockerfile
@@ -0,0 +1,5 @@
+FROM nginx:alpine
+COPY src /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
\ No newline at end of file
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/nginx.conf b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/nginx.conf
new file mode 100755
index 0000000..273e6d8
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/nginx.conf
@@ -0,0 +1,38 @@
+server {
+ listen 80;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Frontend
+ location / {
+ try_files $uri /index.html;
+ }
+
+ # Backend API
+ location /api/ {
+ proxy_pass http://backend:3001;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+
+ # Socket.IO WebSocket proxying
+ location /socket.io/ {
+ proxy_pass http://backend:3001;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location /avatar/ {
+ proxy_pass http://backend:3001/avatar/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_hide_header Content-Type;
+ add_header Cache-Control "public, max-age=3600";
+ }
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/ajout du multiplayer b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/ajout du multiplayer
new file mode 100755
index 0000000..a02e738
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/ajout du multiplayer
@@ -0,0 +1,45 @@
+Je veux faire un mode duel du tetris,
+il est fonctionnel est solo.
+
+Pour commencer, je vais devoir
+creer une div qui sera le rendu
+du joueur second joueur. il sera a droite de la
+div principale qui lui meme sera legerement decale vers la gauche,
+cette div sera grossierement identitique en style qui la div principale
+
+je ne vais pas voir en temps reelle
+les pieces du joueur qui tombe.
+A la place quand le joueur 2 a mis une piece, il envoie un signal,
+le joueur 1 envoie un signal egalement quand la piece tombe.
+
+en mode duel, quand une ligne est clear, il envoie la ligne
+moins la cellule qui a provoquer le clear (donc avec un trou pile
+la ou la derniere piece est arrive) au joueur opposant.
+
+Ce qui a pour effet de decaler toute les lignes vers le haut
+a l'opposant pour recevoir la ligne recu avec le trou.
+
+Pour se faire je vais devoir connecter les deux joueurs.
+
+Il me faudra :
+
+une class Duel il aura pour methode et membre:
+
+action_queue: c'est un tableau qui repertorie tous les
+signaux a traiter, c'est un tableau qui sera partager
+entre le joueur1 et le joueur2
+
+
+Syncronise_game: fonction qui traite les
+actions de action_queue et qui verifie l'integrite du duel,
+il va par exemple regarder l'etat du jeu de chaque joueur
+pour voir s'il correspond bien a ce qui est attendu
+
+il y aura different type de signaux.
+Le signal bloc pose avec le type de bloc,
+sa rotation et as position
+
+le signal line cleared, avec le nombre de ligne
+cleared et on ajoute le trou aussi
+
+Aucune idee de si je dois utiliser web socket ou autre
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/app.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/app.js
new file mode 100755
index 0000000..02998e6
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/app.js
@@ -0,0 +1,113 @@
+/**
+ * Application entry point
+ * Initializes windows and handles menu interactions
+ */
+import { windowRegistry } from './windows.js';
+import { LoginWindow } from './login.js';
+import { GlobalChat } from './global_chat.js';
+import { AvatarWindow } from './avatar.js';
+import { FriendsWindow } from './friends.js';
+import { GameRoomWindow } from './game_room.js';
+
+/**
+ * Main application class
+ * Handles initialization and menu interactions
+ */
+class App {
+ constructor() {
+ this.initWindows();
+ this.initMenu();
+ this.initPage();
+ this.initEasterEgg();
+ }
+
+ /**
+ * Initializes all windows
+ */
+ initWindows() {
+ new LoginWindow();
+ new GlobalChat();
+ new AvatarWindow();
+ new FriendsWindow();
+ new GameRoomWindow();
+ }
+
+ /**
+ * Initializes the main menu
+ * Uses event delegation instead of IDs
+ */
+ initMenu() {
+ const menu = document.querySelector('.menu');
+ if (!menu) {
+ console.warn('Menu not found');
+ return;
+ }
+
+ const actionMap = {
+ 'login': 'login',
+ 'chat': 'chat',
+ 'avatar': 'avatar',
+ 'friends': 'friends'
+ };
+
+ // Event delegation on the menu
+ menu.addEventListener('click', (e) => {
+ const button = e.target.closest('.menu__item');
+ if (!button) return;
+
+ const action = button.dataset.action;
+
+ // Actions with associated windows
+ if (actionMap[action]) {
+ windowRegistry.toggle(actionMap[action]);
+ return;
+ }
+
+ });
+ }
+
+ initPage() {
+ const page = document.querySelector('.page');
+ if (!page) {
+ return;
+ }
+
+ const actionMap = {
+ 'gameroom': 'gameroom'
+ };
+
+ // Event delegation on the menu
+ page.addEventListener('click', (e) => {
+ const button = e.target.closest('.page__item');
+ if (!button) return;
+
+ const action = button.dataset.action;
+
+ // Actions with associated windows
+ if (actionMap[action]) {
+ windowRegistry.toggle(actionMap[action]);
+ return;
+ }
+
+ });
+ }
+
+ /**
+ * Initializes the easter egg button
+ */
+ initEasterEgg() {
+ const easterEgg = document.querySelector('.easter-egg');
+ if (easterEgg) {
+ easterEgg.addEventListener('click', () => {
+ alert('DONT CLICK!');
+ });
+ }
+ }
+}
+
+// Start the application when DOM is ready
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => new App());
+} else {
+ new App();
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/assets/background.png b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/assets/background.png
new file mode 100755
index 0000000..8afd0a3
Binary files /dev/null and b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/assets/background.png differ
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/assets/grwweg.png b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/assets/grwweg.png
new file mode 100755
index 0000000..716affb
Binary files /dev/null and b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/assets/grwweg.png differ
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/avatar.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/avatar.js
new file mode 100755
index 0000000..77dccf3
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/avatar.js
@@ -0,0 +1,271 @@
+import { Window } from './windows.js';
+import { API, STORAGE_KEYS, CSS } from './config.js';
+import { eventBus, Events } from './events.js';
+
+/**
+ * Avatar management window
+ * Allows viewing and modifying the user's avatar
+ */
+export class AvatarWindow extends Window {
+ constructor() {
+ super({
+ name: 'avatar',
+ title: 'Avatar',
+ cssClasses: ['avatar-window']
+ });
+
+ this.buildUI();
+ this.bindEvents();
+ if (localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN)) {
+ this.loadAvatar();
+ }
+
+ // Listen for login events
+ eventBus.on(Events.USER_LOGGED_IN, () => this.loadAvatar());
+ }
+
+ /**
+ * Builds the user interface
+ */
+ buildUI() {
+ // Avatar preview
+ this.preview = this.createElement('img', CSS.AVATAR_PREVIEW, {
+ alt: 'Avatar'
+ });
+
+ // Username display
+ this.username = this.createElement('div', CSS.AVATAR_USERNAME);
+
+ // Stats display
+ this.statsContainer = this.createElement('div', 'avatar__stats');
+ this.pointsDisplay = this.createElement('div', 'avatar__stat');
+ this.gamesPlayedDisplay = this.createElement('div', 'avatar__stat');
+ this.gamesWonDisplay = this.createElement('div', 'avatar__stat');
+ this.statsContainer.append(this.pointsDisplay, this.gamesPlayedDisplay, this.gamesWonDisplay);
+
+ // Hidden file input
+ this.fileInput = this.createElement('input', 'avatar__file-input', {
+ type: 'file',
+ accept: 'image/*'
+ });
+
+ // Controls
+ this.controls = this.createElement('div', CSS.AVATAR_CONTROLS);
+
+ this.chooseBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
+ text: 'Choose image'
+ });
+
+ this.saveBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
+ text: 'Save avatar'
+ });
+
+ this.refreshBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
+ text: 'Refresh'
+ });
+
+ this.controls.append(this.chooseBtn, this.saveBtn, this.refreshBtn);
+
+ // Feedback message
+ this.message = this.createElement('div', CSS.MESSAGE);
+
+ // Assembly
+ this.body.append(
+ this.preview,
+ this.username,
+ this.statsContainer,
+ this.fileInput,
+ this.controls,
+ this.message
+ );
+ }
+
+ /**
+ * Attaches event handlers
+ */
+ bindEvents() {
+ this.fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
+ this.chooseBtn.addEventListener('click', () => this.fileInput.click());
+ this.saveBtn.addEventListener('click', () => this.uploadAvatar());
+ this.refreshBtn.addEventListener('click', () => this.loadAvatar());
+ }
+
+ /**
+ * Handles file selection
+ * @param {Event} e
+ */
+ handleFileSelect(e) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (ev) => {
+ this.preview.src = ev.target.result;
+ };
+ reader.readAsDataURL(file);
+ }
+
+ /**
+ * Decodes a JWT token and returns the payload
+ * @param {string} token
+ * @returns {object|null}
+ */
+ decodeToken(token) {
+ try {
+ const payload = token.split('.')[1];
+ return JSON.parse(atob(payload));
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Loads avatar from the server
+ */
+ async loadAvatar() {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token) {
+ console.log('No token, skipping avatar load');
+ return;
+ }
+
+ // Extract username from JWT token
+ const tokenData = this.decodeToken(token);
+ if (tokenData?.username) {
+ this.username.textContent = tokenData.username;
+ }
+
+ try {
+ const response = await fetch(API.AVATAR.GET, {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${token}`
+ }
+ });
+
+ if (!response.ok) {
+ console.warn('Failed to load avatar, status:', response.status);
+ return;
+ }
+
+ const data = await response.json();
+
+ if (data?.avatar_url) {
+ this.preview.src = data.avatar_url;
+ } else {
+ console.warn('Avatar URL not found in response');
+ }
+ } catch (error) {
+ console.error('Error loading avatar:', error);
+ }
+
+ // Load stats
+ await this.loadStats();
+ }
+
+ /**
+ * Loads player stats from the server
+ */
+ async loadStats() {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token) return;
+
+ try {
+ const response = await fetch(API.STATS.ME, {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${token}`
+ }
+ });
+
+ if (!response.ok) {
+ console.warn('Failed to load stats, status:', response.status);
+ return;
+ }
+
+ const data = await response.json();
+ this.updateStatsDisplay(data);
+ } catch (error) {
+ console.error('Error loading stats:', error);
+ }
+ }
+
+ /**
+ * Updates the stats display
+ * @param {object} stats
+ */
+ updateStatsDisplay(stats) {
+ this.pointsDisplay.innerHTML = `Points: ${stats.total_points || 0}`;
+ this.gamesPlayedDisplay.innerHTML = `Parties: ${stats.games_played || 0}`;
+ this.gamesWonDisplay.innerHTML = `Victoires: ${stats.games_won || 0}`;
+ }
+
+ /**
+ * Uploads avatar to the server
+ */
+ async uploadAvatar() {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token) {
+ this.showMessage('You must be logged in', 'error');
+ return;
+ }
+
+ const file = this.fileInput.files?.[0];
+ if (!file) {
+ this.showMessage('Select an image first', 'error');
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append('avatar', file);
+
+ try {
+ this.showMessage('Uploading...', 'info');
+
+ const response = await fetch(API.AVATAR.UPLOAD, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`
+ },
+ body: formData
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ const errorMsg = data?.error || data?.message || 'Upload failed';
+ this.showMessage(errorMsg, 'error');
+ return;
+ }
+
+ if (data?.avatar_url) {
+ this.preview.src = data.avatar_url;
+ }
+
+ this.showMessage('Avatar saved!', 'success');
+ eventBus.emit(Events.AVATAR_UPDATED, { url: data?.avatar_url });
+
+ } catch (error) {
+ console.error('Avatar upload error:', error);
+ this.showMessage('Upload error', 'error');
+ }
+ }
+
+ /**
+ * Displays a feedback message
+ * @param {string} text - Message text
+ * @param {'success'|'error'|'info'} type - Message type
+ */
+ showMessage(text, type = 'info') {
+ this.message.textContent = text;
+ this.message.className = CSS.MESSAGE;
+
+ if (type === 'success') {
+ this.message.classList.add(CSS.MESSAGE_SUCCESS);
+ } else if (type === 'error') {
+ this.message.classList.add(CSS.MESSAGE_ERROR);
+ } else {
+ this.message.classList.add(CSS.MESSAGE_INFO);
+ }
+ }
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/config.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/config.js
new file mode 100755
index 0000000..aa39164
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/config.js
@@ -0,0 +1,145 @@
+/**
+ * Centralized application configuration
+ */
+
+// API Endpoints
+export const API = {
+ AUTH: {
+ LOGIN: '/api/auth/login',
+ REGISTER: '/api/auth/register',
+ GITHUB: '/api/auth/github'
+ },
+ AVATAR: {
+ GET: '/api/avatar/me',
+ UPLOAD: '/api/avatar/upload'
+ },
+ FRIENDS: {
+ LIST: '/api/friends',
+ REQUESTS: '/api/friends/requests',
+ SEARCH: '/api/friends/search',
+ REQUEST: '/api/friends/request',
+ ACCEPT: '/api/friends/accept',
+ DECLINE: '/api/friends/decline'
+ },
+ ROOMS: {
+ LIST: '/api/rooms',
+ PLAYING: '/api/rooms/playing',
+ CREATE: '/api/rooms',
+ GET: (id) => `/api/rooms/${id}`,
+ PLAYERS: (id) => `/api/rooms/${id}/players`,
+ JOIN: (id) => `/api/rooms/${id}/join`,
+ LEAVE: (id) => `/api/rooms/${id}/leave`,
+ SPECTATE: (id) => `/api/rooms/${id}/spectate`,
+ LEAVE_SPECTATE: (id) => `/api/rooms/${id}/leave-spectate`,
+ CURRENT: '/api/rooms/current'
+ },
+ STATS: {
+ ME: '/api/stats/me',
+ USER: (username) => `/api/stats/user/${username}`,
+ LEADERBOARD: '/api/stats/leaderboard'
+ }
+};
+
+// localStorage keys
+export const STORAGE_KEYS = {
+ AUTH_TOKEN: 'auth_token'
+};
+
+// Window configuration
+export const WINDOW_CONFIG = {
+ DEFAULT_WIDTH: 320,
+ DEFAULT_HEIGHT: 220,
+ Z_INDEX_BASE: 100
+};
+
+// CSS classes (BEM convention)
+export const CSS = {
+ // Menu
+ MENU: 'menu',
+ MENU_ITEM: 'menu__item',
+ MENU_ITEM_ACTIVE: 'menu__item--active',
+
+ // Windows
+ WINDOW: 'window',
+ WINDOW_VISIBLE: 'window--visible',
+ WINDOW_HEADER: 'window__header',
+ WINDOW_TITLE: 'window__title',
+ WINDOW_CLOSE: 'window__close',
+ WINDOW_BODY: 'window__body',
+
+ // Buttons
+ BTN: 'btn',
+ BTN_PRIMARY: 'btn--primary',
+ BTN_SECONDARY: 'btn--secondary',
+ BTN_SUCCESS: 'btn--success',
+ BTN_DANGER: 'btn--danger',
+ BTN_GITHUB: 'btn--github',
+
+ // Forms
+ INPUT: 'input',
+ INPUT_GROUP: 'input-group',
+
+ // Messages
+ MESSAGE: 'message',
+ MESSAGE_SUCCESS: 'message--success',
+ MESSAGE_ERROR: 'message--error',
+ MESSAGE_INFO: 'message--info',
+
+ // Chat
+ CHAT: 'chat',
+ CHAT_OUTPUT: 'chat__output',
+ CHAT_INPUT: 'chat__input',
+ CHAT_CONTROLS: 'chat__controls',
+ CHAT_MESSAGE: 'chat__message',
+ CHAT_SYSTEM: 'chat__system',
+
+ // Avatar
+ AVATAR: 'avatar',
+ AVATAR_PREVIEW: 'avatar__preview',
+ AVATAR_CONTROLS: 'avatar__controls',
+ AVATAR_USERNAME: 'avatar__username',
+
+ // Friends
+ FRIENDS: 'friends',
+ FRIENDS_TABS: 'friends__tabs',
+ FRIENDS_TAB: 'friends__tab',
+ FRIENDS_TAB_ACTIVE: 'friends__tab--active',
+ FRIENDS_CONTENT: 'friends__content',
+ FRIENDS_LIST: 'friends__list',
+ FRIENDS_ITEM: 'friends__item',
+ FRIENDS_AVATAR: 'friends__avatar',
+ FRIENDS_NAME: 'friends__name',
+ FRIENDS_ACTIONS: 'friends__actions',
+ FRIENDS_SEARCH: 'friends__search',
+ FRIENDS_EMPTY: 'friends__empty',
+
+ // Game Rooms
+ GAMEROOM: 'gameroom',
+ GAMEROOM_TABS: 'gameroom__tabs',
+ GAMEROOM_TAB: 'gameroom__tab',
+ GAMEROOM_TAB_ACTIVE: 'gameroom__tab--active',
+ GAMEROOM_CONTENT: 'gameroom__content',
+ GAMEROOM_LIST: 'gameroom__list',
+ GAMEROOM_ITEM: 'gameroom__item',
+ GAMEROOM_NAME: 'gameroom__name',
+ GAMEROOM_PLAYERS: 'gameroom__players',
+ GAMEROOM_ACTIONS: 'gameroom__actions',
+ GAMEROOM_CREATE: 'gameroom__create',
+ GAMEROOM_LOBBY: 'gameroom__lobby',
+ GAMEROOM_PLAYER_LIST: 'gameroom__player-list',
+ GAMEROOM_PLAYER: 'gameroom__player',
+ GAMEROOM_PLAYER_AVATAR: 'gameroom__player-avatar',
+ GAMEROOM_PLAYER_NAME: 'gameroom__player-name',
+ GAMEROOM_PLAYER_SCORE: 'gameroom__player-score'
+};
+
+// Colors (for reference, mainly used in CSS)
+export const COLORS = {
+ PRIMARY: '#0066cc',
+ SUCCESS: '#3cff01',
+ ERROR: '#ff4d4d',
+ GITHUB: '#24292e',
+ BACKGROUND: '#000',
+ SURFACE: '#222',
+ TEXT: '#fff'
+};
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/duel.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/duel.js
new file mode 100755
index 0000000..1df4822
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/duel.js
@@ -0,0 +1,148 @@
+// ─────────────────────────────────────────────
+// DUEL
+// ─────────────────────────────────────────────
+
+class Duel {
+ constructor(socket, tetrisGame, onStatusChange, onStart) {
+ this.socket = socket;
+ this.tetrisGame = tetrisGame;
+ this.onStatusChange = onStatusChange; // (status, opponentName) => void
+ this.onStart = onStart; // () => void — déclenche le début du jeu local
+
+ this.action_queue = [];
+ this.opponentGrid = this._emptyGrid();
+ this.opponentScore = 0;
+ this.roomCode = null;
+ this.isReady = false;
+
+ this._bindSocketEvents();
+ }
+
+ // ─── Connexion ────────────────────────────
+
+ join(roomCode) {
+ this.roomCode = roomCode;
+ this.socket.emit('tetris:join', { roomCode });
+ }
+
+ startDuel() {
+ if (!this.isReady) return;
+ this.socket.emit('tetris:start-duel');
+ }
+
+ leave() {
+ if (!this.roomCode) return;
+ this.socket.emit('tetris:leave');
+ this.roomCode = null;
+ this.isReady = false;
+ this.opponentGrid = this._emptyGrid();
+ this.opponentScore = 0;
+ }
+
+ // ─── Hooks appelés par tetris.js ──────────
+
+ onLocalBlockPlaced(grid, score) {
+ if (!this.isReady) return;
+ this.socket.emit('tetris:grid-update', { grid, score });
+ }
+
+ onLocalLinesCleared(count, holeCol) {
+ if (!this.isReady) return;
+ const garbageLines = [];
+ for (let i = 0; i < count; i++)
+ garbageLines.push(this._buildGarbageLine(holeCol));
+ this.socket.emit('tetris:lines-cleared', { count, holeCol, garbageLines });
+ }
+
+ onLocalGameOver(score) {
+ if (!this.isReady) return;
+ this.socket.emit('tetris:game-over', { score });
+ }
+
+ // ─── Traitement de la queue ───────────────
+
+ synchronize_game() {
+ while (this.action_queue.length > 0) {
+ const action = this.action_queue.shift();
+ this._processAction(action);
+ }
+ }
+
+ _processAction(action) {
+ switch (action.type) {
+ case 'GRID_UPDATE':
+ this.opponentGrid = action.grid;
+ this.opponentScore = action.score;
+ document.getElementById('opponent-score').textContent = action.score;
+ renderOpponent(this.opponentGrid);
+ break;
+
+ case 'LINES_CLEARED':
+ this.tetrisGame.addGarbageLines(action.garbageLines);
+ break;
+
+ case 'OPPONENT_GAME_OVER':
+ this._showOpponentOverlay('YOU WIN', action.score);
+ break;
+ }
+ }
+
+ // ─── Liaison socket ───────────────────────
+
+ _bindSocketEvents() {
+ this.socket.on('tetris:room-status', (data) => {
+ this.isReady = data.status === 'ready';
+ const opponentName = data.players.find(p => p !== this.socket.username) || 'Adversaire';
+ document.getElementById('opponent-name').textContent = opponentName;
+ this.onStatusChange(data.status, opponentName);
+ });
+
+ this.socket.on('tetris:opponent-joined', (data) => {
+ document.getElementById('opponent-name').textContent = data.username;
+ });
+
+ this.socket.on('tetris:opponent-left', () => {
+ this.isReady = false;
+ this.onStatusChange('waiting', null);
+ this._showOpponentOverlay('DÉCONNECTÉ');
+ });
+
+ this.socket.on('tetris:grid-update', (data) => {
+ this.action_queue.push({ type: 'GRID_UPDATE', grid: data.grid, score: data.score });
+ });
+
+ this.socket.on('tetris:lines-cleared', (data) => {
+ this.action_queue.push({ type: 'LINES_CLEARED', garbageLines: data.garbageLines });
+ });
+
+ this.socket.on('tetris:opponent-game-over', (data) => {
+ this.action_queue.push({ type: 'OPPONENT_GAME_OVER', score: data.score });
+ });
+
+ this.socket.on('tetris:start-duel', () => {
+ if (this.onStart) this.onStart();
+ });
+ }
+
+ // ─── Utilitaires ─────────────────────────
+
+ _buildGarbageLine(holeCol) {
+ return Array.from({ length: 10 }, (_, i) => i === holeCol ? 0 : 8);
+ }
+
+ _emptyGrid() {
+ return Array.from({ length: 20 }, () => Array(10).fill(0));
+ }
+
+ _showOpponentOverlay(title, score) {
+ const overlayEl = document.getElementById('overlay-opponent');
+ document.getElementById('overlay-opponent-title').textContent = title;
+ const scoreEl = document.getElementById('overlay-opponent-score');
+ if (scoreEl) scoreEl.textContent = score !== undefined ? `Score : ${score}` : '';
+ overlayEl.classList.add('visible');
+ }
+
+ hideOpponentOverlay() {
+ document.getElementById('overlay-opponent').classList.remove('visible');
+ }
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/element.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/element.js
new file mode 100755
index 0000000..e46e637
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/element.js
@@ -0,0 +1,114 @@
+/**
+ * DOM element utilities
+ * This module provides helper functions for creating elements
+ * without depending on specific HTML IDs
+ */
+
+/**
+ * Creates a DOM element with options
+ * @param {string} tag - HTML tag
+ * @param {Object} options - Configuration options
+ * @param {string|string[]} [options.classes] - CSS classes
+ * @param {string} [options.text] - Element text
+ * @param {string} [options.html] - Inner HTML
+ * @param {Object} [options.attrs] - Additional attributes
+ * @param {Object} [options.style] - Inline styles (avoid using)
+ * @param {Object} [options.events] - Events to attach
+ * @param {HTMLElement[]} [options.children] - Child elements
+ * @returns {HTMLElement}
+ */
+export function createElement(tag, options = {}) {
+ const element = document.createElement(tag);
+
+ // Classes
+ if (options.classes) {
+ const classes = Array.isArray(options.classes) ? options.classes : [options.classes];
+ element.className = classes.filter(Boolean).join(' ');
+ }
+
+ // Text
+ if (options.text) {
+ element.textContent = options.text;
+ }
+
+ // HTML
+ if (options.html) {
+ element.innerHTML = options.html;
+ }
+
+ // Attributes
+ if (options.attrs) {
+ Object.entries(options.attrs).forEach(([key, value]) => {
+ element.setAttribute(key, value);
+ });
+ }
+
+ // Styles (use sparingly)
+ if (options.style) {
+ Object.assign(element.style, options.style);
+ }
+
+ // Events
+ if (options.events) {
+ Object.entries(options.events).forEach(([event, handler]) => {
+ element.addEventListener(event, handler);
+ });
+ }
+
+ // Children
+ if (options.children) {
+ options.children.forEach(child => {
+ if (child) element.appendChild(child);
+ });
+ }
+
+ return element;
+}
+
+/**
+ * Selects an element by its data-attribute
+ * @param {string} attr - Attribute name (without 'data-')
+ * @param {string} value - Value to search for
+ * @param {HTMLElement} [parent=document] - Parent element
+ * @returns {HTMLElement|null}
+ */
+export function findByData(attr, value, parent = document) {
+ return parent.querySelector(`[data-${attr}="${value}"]`);
+}
+
+/**
+ * Selects all elements by their data-attribute
+ * @param {string} attr - Attribute name (without 'data-')
+ * @param {string} [value] - Value to search for (optional)
+ * @param {HTMLElement} [parent=document] - Parent element
+ * @returns {HTMLElement[]}
+ */
+export function findAllByData(attr, value, parent = document) {
+ const selector = value ? `[data-${attr}="${value}"]` : `[data-${attr}]`;
+ return Array.from(parent.querySelectorAll(selector));
+}
+
+/**
+ * Adds or removes a class based on a condition
+ * @param {HTMLElement} element
+ * @param {string} className
+ * @param {boolean} condition
+ */
+export function toggleClass(element, className, condition) {
+ if (condition) {
+ element.classList.add(className);
+ } else {
+ element.classList.remove(className);
+ }
+}
+
+/**
+ * Escapes HTML to prevent XSS
+ * @param {string} text
+ * @returns {string}
+ */
+export function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/events.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/events.js
new file mode 100755
index 0000000..e82be3c
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/events.js
@@ -0,0 +1,98 @@
+/**
+ * EventBus - Centralized event system
+ * Enables communication between modules without circular dependencies
+ */
+class EventBus {
+ constructor() {
+ this.listeners = new Map();
+ }
+
+ /**
+ * Subscribe to an event
+ * @param {string} event - Event name
+ * @param {Function} callback - Function to call
+ * @returns {Function} Unsubscribe function
+ */
+ on(event, callback) {
+ if (!this.listeners.has(event)) {
+ this.listeners.set(event, new Set());
+ }
+ this.listeners.get(event).add(callback);
+
+ return () => this.off(event, callback);
+ }
+
+ /**
+ * Subscribe to an event once
+ * @param {string} event - Event name
+ * @param {Function} callback - Function to call
+ */
+ once(event, callback) {
+ const wrapper = (data) => {
+ this.off(event, wrapper);
+ callback(data);
+ };
+ this.on(event, wrapper);
+ }
+
+ /**
+ * Unsubscribe from an event
+ * @param {string} event - Event name
+ * @param {Function} callback - Function to remove
+ */
+ off(event, callback) {
+ if (this.listeners.has(event)) {
+ this.listeners.get(event).delete(callback);
+ }
+ }
+
+ /**
+ * Emit an event
+ * @param {string} event - Event name
+ * @param {*} data - Data to transmit
+ */
+ emit(event, data) {
+ console.log(`EventBus: Emitting event "${event}"`, data);
+ if (this.listeners.has(event)) {
+ const listeners = this.listeners.get(event);
+ console.log(`EventBus: "${event}" has ${listeners.size} listener(s)`);
+ this.listeners.get(event).forEach(callback => {
+ try {
+ callback(data);
+ } catch (error) {
+ console.error(`Error in listener for "${event}":`, error);
+ }
+ });
+ } else {
+ console.warn(`EventBus: No listeners for event "${event}"`);
+ }
+ }
+}
+
+// Exported singleton instance
+export const eventBus = new EventBus();
+
+// Available events (for documentation and autocompletion)
+export const Events = {
+ // Authentication
+ USER_LOGGED_IN: 'user:logged-in',
+ USER_LOGGED_OUT: 'user:logged-out',
+ USER_REGISTERED: 'user:registered',
+
+ // Windows
+ WINDOW_OPENED: 'window:opened',
+ WINDOW_CLOSED: 'window:closed',
+
+ // Avatar
+ AVATAR_UPDATED: 'avatar:updated',
+
+ // Chat
+ CHAT_CONNECTED: 'chat:connected',
+ CHAT_DISCONNECTED: 'chat:disconnected',
+ CHAT_MESSAGE_RECEIVED: 'chat:message-received',
+
+ // Game Rooms
+ ROOM_JOINED: 'room:joined',
+ ROOM_LEFT: 'room:left',
+ ROOM_CREATED: 'room:created'
+};
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/friends.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/friends.js
new file mode 100755
index 0000000..19d042e
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/friends.js
@@ -0,0 +1,453 @@
+import { Window } from './windows.js';
+import { API, STORAGE_KEYS, CSS } from './config.js';
+import { eventBus, Events } from './events.js';
+
+/**
+ * Friends management window
+ * Allows viewing friends, requests, and searching users
+ */
+export class FriendsWindow extends Window {
+ constructor() {
+ super({
+ name: 'friends',
+ title: 'Amis',
+ cssClasses: ['friends-window']
+ });
+
+ this.currentTab = 'friends';
+ this.buildUI();
+ this.bindEvents();
+
+ eventBus.on(Events.USER_LOGGED_IN, () => this.loadCurrentTab());
+ }
+
+ /**
+ * Builds the user interface
+ */
+ buildUI() {
+ // Tabs
+ this.tabs = this.createElement('div', CSS.FRIENDS_TABS);
+
+ this.friendsTab = this.createElement('button', [CSS.FRIENDS_TAB, CSS.FRIENDS_TAB_ACTIVE], {
+ text: 'Amis'
+ });
+ this.friendsTab.dataset.tab = 'friends';
+
+ this.requestsTab = this.createElement('button', CSS.FRIENDS_TAB, {
+ text: 'Demandes'
+ });
+ this.requestsTab.dataset.tab = 'requests';
+
+ this.searchTab = this.createElement('button', CSS.FRIENDS_TAB, {
+ text: 'Rechercher'
+ });
+ this.searchTab.dataset.tab = 'search';
+
+ this.tabs.append(this.friendsTab, this.requestsTab, this.searchTab);
+
+ // Content area
+ this.content = this.createElement('div', CSS.FRIENDS_CONTENT);
+
+ // Search input (hidden by default)
+ this.searchContainer = this.createElement('div', CSS.FRIENDS_SEARCH);
+ this.searchInput = this.createElement('input', CSS.INPUT, {
+ type: 'text',
+ placeholder: 'Rechercher un utilisateur...'
+ });
+ this.searchBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
+ text: 'Chercher'
+ });
+ this.searchContainer.append(this.searchInput, this.searchBtn);
+ this.searchContainer.style.display = 'none';
+
+ // List container
+ this.list = this.createElement('div', CSS.FRIENDS_LIST);
+
+ // Message
+ this.message = this.createElement('div', CSS.MESSAGE);
+
+ this.content.append(this.searchContainer, this.list, this.message);
+
+ // Assembly
+ this.body.append(this.tabs, this.content);
+ }
+
+ /**
+ * Attaches event handlers
+ */
+ bindEvents() {
+ this.tabs.addEventListener('click', (e) => {
+ const tab = e.target.closest(`.${CSS.FRIENDS_TAB}`);
+ if (tab) {
+ this.switchTab(tab.dataset.tab);
+ }
+ });
+
+ this.searchBtn.addEventListener('click', () => this.searchUsers());
+ this.searchInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') this.searchUsers();
+ });
+ }
+
+ /**
+ * Switches between tabs
+ */
+ switchTab(tabName) {
+ this.currentTab = tabName;
+
+ // Update tab styles
+ [this.friendsTab, this.requestsTab, this.searchTab].forEach(tab => {
+ tab.classList.toggle(CSS.FRIENDS_TAB_ACTIVE, tab.dataset.tab === tabName);
+ });
+
+ // Show/hide search
+ this.searchContainer.style.display = tabName === 'search' ? 'flex' : 'none';
+
+ this.loadCurrentTab();
+ }
+
+ /**
+ * Loads data for current tab
+ */
+ loadCurrentTab() {
+ switch (this.currentTab) {
+ case 'friends':
+ this.loadFriends();
+ break;
+ case 'requests':
+ this.loadRequests();
+ break;
+ case 'search':
+ this.list.innerHTML = '';
+ this.showMessage('Entrez un nom pour rechercher', 'info');
+ break;
+ }
+ }
+
+ /**
+ * Gets auth headers
+ */
+ getHeaders() {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ return {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json'
+ };
+ }
+
+ /**
+ * Loads friends list
+ */
+ async loadFriends() {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token) {
+ this.showMessage('Connectez-vous pour voir vos amis', 'info');
+ return;
+ }
+
+ try {
+ const response = await fetch(API.FRIENDS.LIST, {
+ headers: this.getHeaders()
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.showMessage(data.error || 'Erreur', 'error');
+ return;
+ }
+
+ this.renderFriendsList(data.friends || []);
+ } catch (error) {
+ console.error('Load friends error:', error);
+ this.showMessage('Erreur de connexion', 'error');
+ }
+ }
+
+ /**
+ * Loads pending requests
+ */
+ async loadRequests() {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token) {
+ this.showMessage('Connectez-vous pour voir les demandes', 'info');
+ return;
+ }
+
+ try {
+ const response = await fetch(API.FRIENDS.REQUESTS, {
+ headers: this.getHeaders()
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.showMessage(data.error || 'Erreur', 'error');
+ return;
+ }
+
+ this.renderRequestsList(data.requests || []);
+ } catch (error) {
+ console.error('Load requests error:', error);
+ this.showMessage('Erreur de connexion', 'error');
+ }
+ }
+
+ /**
+ * Searches users
+ */
+ async searchUsers() {
+ const query = this.searchInput.value.trim();
+ if (!query) {
+ this.showMessage('Entrez un nom pour rechercher', 'info');
+ return;
+ }
+
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token) {
+ this.showMessage('Connectez-vous pour rechercher', 'info');
+ return;
+ }
+
+ try {
+ const response = await fetch(`${API.FRIENDS.SEARCH}?q=${encodeURIComponent(query)}`, {
+ headers: this.getHeaders()
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.showMessage(data.error || 'Erreur', 'error');
+ return;
+ }
+
+ this.renderSearchResults(data.users || []);
+ } catch (error) {
+ console.error('Search error:', error);
+ this.showMessage('Erreur de connexion', 'error');
+ }
+ }
+
+ /**
+ * Renders friends list
+ */
+ renderFriendsList(friends) {
+ this.list.innerHTML = '';
+ this.message.textContent = '';
+
+ if (friends.length === 0) {
+ this.showMessage('Aucun ami pour le moment', 'info');
+ return;
+ }
+
+ friends.forEach(friend => {
+ const item = this.createFriendItem(friend, 'friend');
+ this.list.appendChild(item);
+ });
+ }
+
+ /**
+ * Renders requests list
+ */
+ renderRequestsList(requests) {
+ this.list.innerHTML = '';
+ this.message.textContent = '';
+
+ if (requests.length === 0) {
+ this.showMessage('Aucune demande en attente', 'info');
+ return;
+ }
+
+ requests.forEach(request => {
+ const item = this.createFriendItem(request, 'request');
+ this.list.appendChild(item);
+ });
+ }
+
+ /**
+ * Renders search results
+ */
+ renderSearchResults(users) {
+ this.list.innerHTML = '';
+ this.message.textContent = '';
+
+ if (users.length === 0) {
+ this.showMessage('Aucun utilisateur trouve', 'info');
+ return;
+ }
+
+ users.forEach(user => {
+ const item = this.createFriendItem(user, 'search');
+ this.list.appendChild(item);
+ });
+ }
+
+ /**
+ * Creates a friend/user item
+ */
+ createFriendItem(user, type) {
+ const item = this.createElement('div', CSS.FRIENDS_ITEM);
+
+ const avatar = this.createElement('img', CSS.FRIENDS_AVATAR, {
+ alt: user.username
+ });
+ avatar.src = user.avatar_url || '/avatar/default.png';
+
+ const infoContainer = this.createElement('div', 'friends__info');
+
+ const name = this.createElement('span', CSS.FRIENDS_NAME, {
+ text: user.username
+ });
+
+ infoContainer.appendChild(name);
+
+ // Show stats for friends
+ if (type === 'friend' && user.total_points !== undefined) {
+ const stats = this.createElement('span', 'friends__stats', {
+ text: `${user.total_points || 0} pts`
+ });
+ infoContainer.appendChild(stats);
+ }
+
+ const actions = this.createElement('div', CSS.FRIENDS_ACTIONS);
+
+ if (type === 'friend') {
+ const removeBtn = this.createElement('button', [CSS.BTN, CSS.BTN_DANGER], {
+ text: 'Retirer'
+ });
+ removeBtn.addEventListener('click', () => this.removeFriend(user.id));
+ actions.appendChild(removeBtn);
+ } else if (type === 'request') {
+ const acceptBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SUCCESS], {
+ text: 'Accepter'
+ });
+ acceptBtn.addEventListener('click', () => this.acceptRequest(user.id));
+
+ const declineBtn = this.createElement('button', [CSS.BTN, CSS.BTN_DANGER], {
+ text: 'Refuser'
+ });
+ declineBtn.addEventListener('click', () => this.declineRequest(user.id));
+
+ actions.append(acceptBtn, declineBtn);
+ } else if (type === 'search') {
+ const addBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
+ text: 'Ajouter'
+ });
+ addBtn.addEventListener('click', () => this.sendRequest(user.id, addBtn));
+ actions.appendChild(addBtn);
+ }
+
+ item.append(avatar, infoContainer, actions);
+ return item;
+ }
+
+ /**
+ * Sends a friend request
+ */
+ async sendRequest(userId, button) {
+ try {
+ const response = await fetch(`${API.FRIENDS.REQUEST}/${userId}`, {
+ method: 'POST',
+ headers: this.getHeaders()
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.showMessage(data.error || 'Erreur', 'error');
+ return;
+ }
+
+ button.textContent = 'Envoye';
+ button.disabled = true;
+ this.showMessage('Demande envoyee', 'success');
+ } catch (error) {
+ console.error('Send request error:', error);
+ this.showMessage('Erreur', 'error');
+ }
+ }
+
+ /**
+ * Accepts a friend request
+ */
+ async acceptRequest(userId) {
+ try {
+ const response = await fetch(`${API.FRIENDS.ACCEPT}/${userId}`, {
+ method: 'POST',
+ headers: this.getHeaders()
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.showMessage(data.error || 'Erreur', 'error');
+ return;
+ }
+
+ this.showMessage('Ami ajoute', 'success');
+ this.loadRequests();
+ } catch (error) {
+ console.error('Accept request error:', error);
+ this.showMessage('Erreur', 'error');
+ }
+ }
+
+ /**
+ * Declines a friend request
+ */
+ async declineRequest(userId) {
+ try {
+ const response = await fetch(`${API.FRIENDS.DECLINE}/${userId}`, {
+ method: 'POST',
+ headers: this.getHeaders()
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.showMessage(data.error || 'Erreur', 'error');
+ return;
+ }
+
+ this.showMessage('Demande refusee', 'success');
+ this.loadRequests();
+ } catch (error) {
+ console.error('Decline request error:', error);
+ this.showMessage('Erreur', 'error');
+ }
+ }
+
+ /**
+ * Removes a friend
+ */
+ async removeFriend(userId) {
+ try {
+ const response = await fetch(`${API.FRIENDS.LIST}/${userId}`, {
+ method: 'DELETE',
+ headers: this.getHeaders()
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.showMessage(data.error || 'Erreur', 'error');
+ return;
+ }
+
+ this.showMessage('Ami retire', 'success');
+ this.loadFriends();
+ } catch (error) {
+ console.error('Remove friend error:', error);
+ this.showMessage('Erreur', 'error');
+ }
+ }
+
+ /**
+ * Shows a message
+ */
+ showMessage(text, type = 'info') {
+ this.message.textContent = text;
+ this.message.className = CSS.MESSAGE;
+
+ if (type === 'success') {
+ this.message.classList.add(CSS.MESSAGE_SUCCESS);
+ } else if (type === 'error') {
+ this.message.classList.add(CSS.MESSAGE_ERROR);
+ } else {
+ this.message.classList.add(CSS.MESSAGE_INFO);
+ }
+ }
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game.css b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game.css
new file mode 100755
index 0000000..b40f7ff
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game.css
@@ -0,0 +1,1021 @@
+:root {
+ --color-primary: #0066cc;
+ --color-primary-hover: #0052a3;
+ --color-success: #3cff01;
+ --color-success-dark: #28a745;
+ --color-error: #ff4d4d;
+ --color-warning: #ffc107;
+ --color-github: #24292e;
+
+ --color-bg: #000;
+
+ --app-background-base: radial-gradient(
+ circle at top,
+ #1b2735,
+ #090a0f
+ );
+
+ /* --app-background-image: url("./assets/background.png"); */
+
+ --color-surface: #222;
+ --color-surface-light: #333;
+ --color-text: #fff;
+ --color-text-muted: #aaa;
+
+ --font-size-base: 10px;
+ --font-size-sm: 1.2rem;
+ --font-size-md: 1.4rem;
+ --font-size-lg: 1.6rem;
+ --font-size-xl: 3rem;
+
+ --spacing-xs: 4px;
+ --spacing-sm: 8px;
+ --spacing-md: 12px;
+ --spacing-lg: 16px;
+ --spacing-xl: 24px;
+
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 12px;
+ --radius-full: 50%;
+
+ --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.5);
+ --shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.5);
+
+ --transition-fast: 150ms ease;
+ --transition-normal: 250ms ease;
+
+ --z-menu: 2;
+ --z-window: 100;
+ --z-modal: 200;
+}
+
+/* ============================================
+ RESET & BASE
+ ============================================ */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ height: 100%;
+ background-image:
+ var(--app-background-base);
+
+ background-size:
+ contain,
+ cover;
+
+ background-position:
+ center,
+ center;
+
+ background-repeat:
+ no-repeat,
+ no-repeat;
+}
+
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ color: var(--color-text);
+ line-height: 1.5;
+}
+
+.app {
+ position: relative;
+ width: 70%;
+ min-width: 800px;
+ margin: 0 auto;
+}
+
+
+/* ============================================
+ TYPOGRAPHY
+ ============================================ */
+.title {
+ position: absolute;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ text-transform: uppercase;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 20px;
+ font-size: var(--font-size-xl);
+ text-align: center;
+ text-shadow: 2px 2px 10px black;
+ z-index: 1;
+ font-family: "Cinzel Decorative", cursive;
+ color: var(--color-success);
+ margin: 0;
+ padding: var(--spacing-md);
+}
+
+/* ============================================
+ MENU
+ ============================================ */
+
+.menu {
+ position: fixed;
+ top: 0;
+ left: 50px;
+ padding: 0;
+ margin: 0;
+ z-index: var(--z-menu);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+}
+
+.menu__item {
+ background: var(--color-surface);
+ color: var(--color-text);
+ border: 1px solid var(--color-surface-light);
+ padding: var(--spacing-sm) var(--spacing-md);
+ font-size: var(--font-size-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ text-align: left;
+}
+
+.menu__item:hover {
+ background: var(--color-surface-light);
+ font-size: var(--font-size-lg);
+}
+
+.menu__item--active {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+}
+
+/* ============================================
+ GAME
+ ============================================ */
+
+.game {
+ position: fixed;
+ top: 0;
+ right: 50px;
+ padding: 0;
+ margin: 0;
+ z-index: var(--z-menu);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+}
+
+.game__item {
+ background: var(--color-surface);
+ color: var(--color-text);
+ border: 1px solid var(--color-surface-light);
+ padding: var(--spacing-sm) var(--spacing-md);
+ font-size: var(--font-size-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ text-align: right;
+}
+
+.game__item:hover {
+ background: var(--color-surface-light);
+ font-size: var(--font-size-lg);
+}
+
+.game__item--active {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+}
+
+/* ============================================
+ PAGES
+ ============================================ */
+
+.page {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+
+ z-index: var(--z-menu);
+}
+
+.page__item {
+ background: var(--color-surface);
+ color: var(--color-text);
+ border: 1px solid var(--color-surface-light);
+ padding: var(--spacing-sm) var(--spacing-md);
+ font-size: var(--font-size-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ text-align: right;
+}
+
+.page__item:hover {
+ background: var(--color-surface-light);
+ font-size: var(--font-size-lg);
+}
+
+.page__item--active {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+}
+
+/* ============================================
+ BUTTONS
+ ============================================ */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-sm) var(--spacing-md);
+ font-size: var(--font-size-md);
+ font-weight: 500;
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ text-decoration: none;
+}
+
+.btn:hover {
+ opacity: 0.9;
+ transform: translateY(-1px);
+}
+
+.btn:active {
+ transform: translateY(0);
+}
+
+.btn--primary {
+ background: var(--color-primary);
+ color: var(--color-text);
+}
+
+.btn--primary:hover {
+ background: var(--color-primary-hover);
+}
+
+.btn--secondary {
+ background: var(--color-surface-light);
+ color: var(--color-text);
+}
+
+.btn--success {
+ background: var(--color-success-dark);
+ color: var(--color-text);
+}
+
+.btn--danger {
+ background: var(--color-error);
+ color: var(--color-text);
+}
+
+.btn--github {
+ background: var(--color-github);
+ color: var(--color-text);
+}
+
+.btn--ghost {
+ background: transparent;
+ color: var(--color-text);
+ border: 1px solid var(--color-surface-light);
+}
+
+/* ============================================
+ INPUTS
+ ============================================ */
+.input {
+ width: 100%;
+ padding: var(--spacing-sm) var(--spacing-md);
+ font-size: var(--font-size-md);
+ background: var(--color-surface);
+ color: var(--color-text);
+ border: 1px solid var(--color-surface-light);
+ border-radius: var(--radius-md);
+ transition: border-color var(--transition-fast);
+}
+
+.input:focus {
+ outline: none;
+ border-color: var(--color-primary);
+}
+
+.input::placeholder {
+ color: var(--color-text-muted);
+}
+
+.input-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+/* ============================================
+ WINDOWS
+ ============================================ */
+.window {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: var(--color-bg);
+ border: 2px ridge var(--color-text);
+ color: var(--color-text);
+ z-index: var(--z-window);
+ display: none;
+ flex-direction: column;
+ min-width: 280px;
+ box-shadow: var(--shadow-lg);
+}
+
+.window--visible {
+ display: flex;
+}
+
+.window--left {
+ left: 25%;
+}
+
+.window--right {
+ left: 75%;
+}
+
+.window__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--spacing-sm) var(--spacing-md);
+ background: var(--color-surface);
+ cursor: move;
+ user-select: none;
+}
+
+.window__title {
+ font-weight: 500;
+ font-size: var(--font-size-md);
+}
+
+.window__close {
+ cursor: pointer;
+ font-size: var(--font-size-lg);
+ opacity: 0.8;
+ transition: opacity var(--transition-fast);
+ background: none;
+ border: none;
+ color: var(--color-text);
+ padding: 0;
+ line-height: 1;
+}
+
+.window__close:hover {
+ opacity: 1;
+}
+
+.window__body {
+ padding: var(--spacing-md);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ flex: 1;
+ overflow: auto;
+}
+
+/* ============================================
+ MESSAGES
+ ============================================ */
+.message {
+ font-size: var(--font-size-sm);
+ padding: var(--spacing-xs);
+ border-radius: var(--radius-sm);
+}
+
+.message--success {
+ color: var(--color-success);
+}
+
+.message--error {
+ color: var(--color-error);
+}
+
+.message--info {
+ color: var(--color-text-muted);
+}
+
+/* ============================================
+ LOGIN WINDOW
+ ============================================ */
+.login {
+ width: 320px;
+}
+
+.login__form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+.login__actions {
+ display: flex;
+ gap: var(--spacing-sm);
+ margin-top: var(--spacing-xs);
+}
+
+.login__divider {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ color: var(--color-text-muted);
+ font-size: var(--font-size-sm);
+ margin: var(--spacing-sm) 0;
+}
+
+.login__divider::before,
+.login__divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--color-surface-light);
+}
+
+/* ============================================
+ CHAT WINDOW
+ ============================================ */
+.chat {
+ width: 380px;
+ height: 400px;
+}
+
+.chat__output {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--spacing-sm);
+ background: var(--color-surface);
+ border-radius: var(--radius-md);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ min-height: 150px;
+}
+
+.chat__message {
+ padding: var(--spacing-xs) var(--spacing-sm);
+ background: var(--color-surface-light);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-size-sm);
+}
+
+.chat__message--own {
+ background: var(--color-primary);
+ align-self: flex-end;
+}
+
+.chat__friend-indicator {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ background-color: var(--color-success);
+ border-radius: 50%;
+ margin-right: var(--spacing-xs);
+ vertical-align: middle;
+}
+
+.chat__system {
+ color: var(--color-text-muted);
+ font-size: var(--font-size-sm);
+ font-style: italic;
+ text-align: center;
+}
+
+.chat__system--error {
+ color: var(--color-error);
+}
+
+.chat__system--success {
+ color: var(--color-success);
+}
+
+.chat__input-container {
+ display: flex;
+ gap: var(--spacing-sm);
+ margin-top: var(--spacing-sm);
+}
+
+.chat__input {
+ flex: 1;
+}
+
+.chat__controls {
+ display: flex;
+ gap: var(--spacing-sm);
+ margin-top: var(--spacing-sm);
+}
+
+/* ============================================
+ AVATAR WINDOW
+ ============================================ */
+.avatar-window {
+ width: 360px;
+}
+
+.avatar__preview {
+ width: 120px;
+ height: 120px;
+ object-fit: cover;
+ border-radius: var(--radius-full);
+ border: 3px solid var(--color-text);
+ box-shadow: var(--shadow-md);
+ background: var(--color-surface);
+ align-self: center;
+}
+
+.avatar__username {
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ text-align: center;
+ color: var(--color-text);
+ margin-top: var(--spacing-sm);
+}
+
+.avatar__controls {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ align-items: center;
+}
+
+.avatar__file-input {
+ display: none;
+}
+
+/* ============================================
+ EASTER EGG BUTTON
+ ============================================ */
+/* .easter-egg {
+ position: absolute;
+ top: 20%;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 1;
+ background: var(--color-surface);
+ color: var(--color-text);
+ border: 1px solid var(--color-surface-light);
+ padding: var(--spacing-sm) var(--spacing-md);
+ cursor: pointer;
+ font-size: var(--font-size-md);
+ border-radius: var(--radius-md);
+ transition: all var(--transition-fast);
+}
+
+.easter-egg:hover {
+ background: var(--color-error);
+ border-color: var(--color-error);
+} */
+
+/* ============================================
+ UTILITIES
+ ============================================ */
+.hidden {
+ display: none !important;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ border: 0;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.flex-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* ============================================
+ FRIENDS WINDOW
+ ============================================ */
+.friends-window {
+ width: 400px;
+ height: 450px;
+}
+
+.friends__tabs {
+ display: flex;
+ gap: var(--spacing-xs);
+ margin-bottom: var(--spacing-sm);
+}
+
+.friends__tab {
+ flex: 1;
+ padding: var(--spacing-sm);
+ background: var(--color-surface);
+ border: 1px solid var(--color-surface-light);
+ color: var(--color-text);
+ cursor: pointer;
+ font-size: var(--font-size-sm);
+ transition: all var(--transition-fast);
+}
+
+.friends__tab:hover {
+ background: var(--color-surface-light);
+}
+
+.friends__tab--active {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+}
+
+.friends__content {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: hidden;
+}
+
+.friends__search {
+ display: flex;
+ gap: var(--spacing-sm);
+ margin-bottom: var(--spacing-sm);
+}
+
+.friends__search .input {
+ flex: 1;
+}
+
+.friends__list {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+.friends__item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm);
+ background: var(--color-surface);
+ border-radius: var(--radius-md);
+}
+
+.friends__avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: var(--radius-full);
+ object-fit: cover;
+ border: 2px solid var(--color-surface-light);
+}
+
+.friends__name {
+ flex: 1;
+ font-size: var(--font-size-md);
+ font-weight: 500;
+}
+
+.friends__actions {
+ display: flex;
+ gap: var(--spacing-xs);
+}
+
+.friends__actions .btn {
+ padding: var(--spacing-xs) var(--spacing-sm);
+ font-size: var(--font-size-sm);
+}
+
+.friends__empty {
+ text-align: center;
+ color: var(--color-text-muted);
+ padding: var(--spacing-lg);
+}
+
+/* ============================================
+ GAME ROOM WINDOW
+ ============================================ */
+.gameroom-window {
+ width: 600px;
+ height: 800px;
+}
+
+.gameroom__tabs {
+ display: flex;
+ gap: var(--spacing-xs);
+ margin-bottom: var(--spacing-sm);
+}
+
+.gameroom__tab {
+ flex: 1;
+ padding: var(--spacing-sm);
+ background: var(--color-surface);
+ border: 1px solid var(--color-surface-light);
+ color: var(--color-text);
+ cursor: pointer;
+ font-size: var(--font-size-sm);
+ transition: all var(--transition-fast);
+}
+
+.gameroom__tab:hover {
+ background: var(--color-surface-light);
+}
+
+.gameroom__tab--active {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+}
+
+.gameroom__content {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: hidden;
+}
+
+.gameroom__create {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ margin-bottom: var(--spacing-sm);
+}
+
+.gameroom__list {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+.gameroom__item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm);
+ background: var(--color-surface);
+ border-radius: var(--radius-md);
+}
+
+.gameroom__name {
+ flex: 1;
+ font-size: var(--font-size-md);
+ font-weight: 500;
+}
+
+.gameroom__players {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ background: var(--color-surface-light);
+ border-radius: var(--radius-sm);
+}
+
+.gameroom__actions {
+ display: flex;
+ gap: var(--spacing-xs);
+}
+
+.gameroom__actions .btn {
+ padding: var(--spacing-xs) var(--spacing-sm);
+ font-size: var(--font-size-sm);
+}
+
+.gameroom__lobby {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ gap: var(--spacing-sm);
+}
+
+.gameroom__lobby-title {
+ margin: 0;
+ font-size: var(--font-size-lg);
+ text-align: center;
+ color: var(--color-success);
+}
+
+.gameroom__player-list {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ background: var(--color-surface);
+ border-radius: var(--radius-md);
+ padding: var(--spacing-sm);
+}
+
+.gameroom__player {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ background: var(--color-surface-light);
+ border-radius: var(--radius-sm);
+}
+
+.gameroom__player-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-full);
+ object-fit: cover;
+ border: 2px solid var(--color-surface-light);
+}
+
+.gameroom__player-name {
+ flex: 1;
+ font-size: var(--font-size-md);
+}
+
+.gameroom__player-stats {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+}
+
+.gameroom__player-score {
+ font-size: var(--font-size-sm);
+ color: var(--color-success);
+ font-weight: 500;
+}
+
+.gameroom__player-total {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+}
+
+.gameroom__empty {
+ text-align: center;
+ color: var(--color-text-muted);
+ padding: var(--spacing-lg);
+}
+
+/* ============================================
+ GAME - JEU DU PENDU/DESSIN
+ ============================================ */
+
+.gameroom__lobby-buttons {
+ display: flex;
+ gap: var(--spacing-sm);
+ margin-top: auto;
+}
+
+.gameroom__lobby-buttons .btn {
+ flex: 1;
+}
+
+.gameroom__game {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ flex: 1;
+}
+
+.gameroom__game-info {
+ text-align: center;
+}
+
+.gameroom__drawer-info {
+ font-size: var(--font-size-md);
+ color: var(--color-text-muted);
+ padding: var(--spacing-xs);
+}
+
+.gameroom__scores-display {
+ font-size: var(--font-size-sm);
+ color: var(--color-success);
+ padding: var(--spacing-xs);
+ background: var(--color-surface);
+ border-radius: var(--radius-sm);
+ margin-top: var(--spacing-xs);
+}
+
+.gameroom__drawer-info--winner {
+ color: var(--color-success);
+ font-weight: bold;
+ animation: pulse 0.5s ease-in-out 3;
+}
+
+@keyframes pulse {
+ 0%, 100% { transform: scale(1); }
+ 50% { transform: scale(1.05); }
+}
+
+.gameroom__word-display {
+ font-size: var(--font-size-xl);
+ font-family: monospace;
+ text-align: center;
+ letter-spacing: 8px;
+ padding: var(--spacing-md);
+ background: var(--color-surface);
+ border-radius: var(--radius-md);
+ min-height: 60px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-success);
+}
+
+.gameroom__canvas-container {
+ display: flex;
+ justify-content: center;
+}
+
+.gameroom__canvas {
+ background: var(--color-surface-light);
+ border-radius: var(--radius-md);
+ cursor: crosshair;
+ border: 2px solid var(--color-surface-light);
+}
+
+.gameroom__draw-tools {
+ display: flex;
+ gap: var(--spacing-sm);
+ justify-content: center;
+ align-items: center;
+}
+
+.gameroom__color-picker {
+ width: 40px;
+ height: 32px;
+ border: none;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ background: transparent;
+}
+
+.gameroom__word-input-container,
+.gameroom__guess-container {
+ display: flex;
+ gap: var(--spacing-sm);
+}
+
+.gameroom__word-input-container .input,
+.gameroom__guess-container .input {
+ flex: 1;
+}
+
+.gameroom__guess-container .input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.gameroom__guess-container .btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.gameroom__guess-history {
+ flex: 1;
+ min-height: 60px;
+ max-height: 100px;
+ overflow-y: auto;
+ background: var(--color-surface);
+ border-radius: var(--radius-md);
+ padding: var(--spacing-sm);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+}
+
+.gameroom__guess-item {
+ font-size: var(--font-size-sm);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ border-radius: var(--radius-sm);
+}
+
+.gameroom__guess-item--success {
+ background: rgba(60, 255, 1, 0.2);
+ color: var(--color-success);
+}
+
+.gameroom__guess-item--fail {
+ background: rgba(255, 77, 77, 0.2);
+ color: var(--color-error);
+}
+
+.gameroom__game-buttons {
+ display: flex;
+ gap: var(--spacing-sm);
+ margin-top: var(--spacing-sm);
+}
+
+.gameroom__game-buttons .btn {
+ flex: 1;
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game.html b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game.html
new file mode 100755
index 0000000..51aef37
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+ Lobby
+
+
+
+
+
+
+ Lobby
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game_room.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game_room.js
new file mode 100755
index 0000000..d5881e9
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game_room.js
@@ -0,0 +1,1580 @@
+import { Window } from './windows.js';
+import { API, STORAGE_KEYS, CSS } from './config.js';
+import { eventBus, Events } from './events.js';
+
+export class GameRoomWindow extends Window {
+ constructor() {
+ super({
+ name: 'gameroom',
+ title: 'Game Rooms',
+ cssClasses: ['gameroom-window']
+ });
+
+ this.currentTab = 'browse';
+ this.currentRoom = null;
+ this.roomsList = [];
+ this.socket = null;
+ this.isSpectating = false;
+ this.messageTimeout = null;
+ this.buildUI();
+ this.bindEvents();
+
+ // Handle page close/refresh to disconnect socket
+ window.addEventListener('beforeunload', () => {
+ if (this.socket?.connected) {
+ this.socket.disconnect();
+ }
+ });
+
+ eventBus.on(Events.USER_LOGGED_IN, () => {
+ this.updateTabsAccess();
+ this.checkCurrentRoom();
+ });
+ eventBus.on(Events.USER_LOGGED_OUT, () => {
+ this.handleLogout();
+ });
+
+ this.updateTabsAccess();
+
+ // Verifier si l'utilisateur est deja dans un salon au chargement
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (token)
+ this.checkCurrentRoom();
+ }
+
+ buildUI() {
+ this.tabs = this.createElement('div', CSS.GAMEROOM_TABS);
+
+ this.browseTab = this.createElement('button', [CSS.GAMEROOM_TAB, CSS.GAMEROOM_TAB_ACTIVE], {
+ text: 'Salons'
+ });
+ this.browseTab.dataset.tab = 'browse';
+
+ this.spectatorTab = this.createElement('button', CSS.GAMEROOM_TAB, {
+ text: 'Spectateur'
+ });
+ this.spectatorTab.dataset.tab = 'spectator';
+
+ this.createTab = this.createElement('button', CSS.GAMEROOM_TAB, {
+ text: 'Creer'
+ });
+ this.createTab.dataset.tab = 'create';
+
+ this.lobbyTab = this.createElement('button', CSS.GAMEROOM_TAB, {
+ text: 'Lobby'
+ });
+ this.lobbyTab.dataset.tab = 'lobby';
+ this.lobbyTab.style.display = 'none';
+
+ this.tabs.append(this.browseTab, this.spectatorTab, this.createTab, this.lobbyTab);
+
+ this.content = this.createElement('div', CSS.GAMEROOM_CONTENT);
+
+ this.createContainer = this.createElement('div', CSS.GAMEROOM_CREATE);
+ this.roomNameInput = this.createElement('input', CSS.INPUT, {
+ type: 'text',
+ placeholder: 'Nom du salon...'
+ });
+ this.createBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
+ text: 'Creer le salon'
+ });
+ this.createContainer.append(this.roomNameInput, this.createBtn);
+ this.createContainer.style.display = 'none';
+
+ this.lobbyContainer = this.createElement('div', CSS.GAMEROOM_LOBBY);
+ this.lobbyTitle = this.createElement('h3', 'gameroom__lobby-title', { text: '' });
+ this.playerList = this.createElement('div', CSS.GAMEROOM_PLAYER_LIST);
+
+ // Boutons du lobby
+ this.lobbyButtons = this.createElement('div', 'gameroom__lobby-buttons');
+ this.startGameBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SUCCESS], {
+ text: 'Lancer le jeu'
+ });
+ this.leaveBtn = this.createElement('button', [CSS.BTN, CSS.BTN_DANGER], {
+ text: 'Quitter'
+ });
+ this.lobbyButtons.append(this.startGameBtn, this.leaveBtn);
+
+ // Container du jeu (caché par défaut)
+ this.gameContainer = this.createElement('div', 'gameroom__game');
+ this.gameContainer.style.display = 'none';
+ this.buildGameUI();
+
+ this.lobbyContainer.append(this.lobbyTitle, this.playerList, this.lobbyButtons, this.gameContainer);
+ this.lobbyContainer.style.display = 'none';
+
+ this.list = this.createElement('div', CSS.GAMEROOM_LIST);
+
+ this.spectatorList = this.createElement('div', CSS.GAMEROOM_LIST);
+ this.spectatorList.style.display = 'none';
+
+ this.message = this.createElement('div', CSS.MESSAGE);
+
+ this.content.append(this.createContainer, this.lobbyContainer, this.list, this.spectatorList, this.message);
+
+ this.body.append(this.tabs, this.content);
+ }
+
+ buildGameUI() {
+ // Zone d'info du jeu
+ this.gameInfo = this.createElement('div', 'gameroom__game-info');
+ this.currentDrawerInfo = this.createElement('div', 'gameroom__drawer-info', { text: '' });
+ this.scoresDisplay = this.createElement('div', 'gameroom__scores-display');
+ this.gameInfo.append(this.currentDrawerInfo, this.scoresDisplay);
+
+ // Affichage du mot caché
+ this.wordDisplay = this.createElement('div', 'gameroom__word-display');
+
+ // Canvas de dessin
+ this.canvasContainer = this.createElement('div', 'gameroom__canvas-container');
+ this.canvas = document.createElement('canvas');
+ this.canvas.className = 'gameroom__canvas';
+ this.canvas.width = 380;
+ this.canvas.height = 200;
+ this.ctx = this.canvas.getContext('2d');
+ this.canvasContainer.appendChild(this.canvas);
+
+ // Outils de dessin
+ this.drawTools = this.createElement('div', 'gameroom__draw-tools');
+ this.colorPicker = this.createElement('input', 'gameroom__color-picker');
+ this.colorPicker.type = 'color';
+ this.colorPicker.value = '#ffffff';
+ this.clearCanvasBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { text: 'Effacer' });
+ this.drawTools.append(this.colorPicker, this.clearCanvasBtn);
+ this.drawTools.style.display = 'none';
+
+ // Zone pour choisir le mot (pour le dessinateur)
+ this.wordInputContainer = this.createElement('div', 'gameroom__word-input-container');
+ this.wordInput = this.createElement('input', CSS.INPUT, {
+ type: 'text',
+ placeholder: 'Entrez le mot a faire deviner...'
+ });
+ this.confirmWordBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { text: 'OK' });
+ this.wordInputContainer.append(this.wordInput, this.confirmWordBtn);
+ this.wordInputContainer.style.display = 'none';
+
+ // Zone pour deviner (pour les autres joueurs)
+ this.guessContainer = this.createElement('div', 'gameroom__guess-container');
+ this.letterInput = this.createElement('input', CSS.INPUT, {
+ type: 'text',
+ placeholder: 'Proposez une lettre ou le mot...',
+ maxLength: '50'
+ });
+ this.guessBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { text: 'Deviner' });
+ this.guessContainer.append(this.letterInput, this.guessBtn);
+ this.guessContainer.style.display = 'none';
+
+ // Historique des tentatives
+ this.guessHistory = this.createElement('div', 'gameroom__guess-history');
+
+ // Boutons du jeu
+ this.gameButtons = this.createElement('div', 'gameroom__game-buttons');
+ this.backToLobbyBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { text: 'Quitter la partie' });
+ this.endRoundBtn = this.createElement('button', [CSS.BTN, CSS.BTN_DANGER], { text: 'Terminer le jeu' });
+ this.gameButtons.append(this.backToLobbyBtn, this.endRoundBtn);
+
+ this.gameContainer.append(
+ this.gameInfo,
+ this.wordDisplay,
+ this.canvasContainer,
+ this.drawTools,
+ this.wordInputContainer,
+ this.guessContainer,
+ this.guessHistory,
+ this.gameButtons
+ );
+
+ // Initialiser les variables du jeu
+ this.gameState = {
+ isPlaying: false,
+ currentWord: '',
+ wordLength: 0,
+ revealedLetters: [],
+ revealedWord: [],
+ drawer: null,
+ players: [],
+ currentPlayerIndex: 0,
+ guessedLetters: [],
+ scores: {}
+ };
+
+ this.initDrawing();
+ }
+
+ initDrawing() {
+ this.isDrawing = false;
+ this.lastX = 0;
+ this.lastY = 0;
+
+ this.canvas.addEventListener('mousedown', (e) => {
+ if (!this.gameState.isPlaying || !this.isCurrentUserDrawer() || this.isSpectating) return;
+ this.isDrawing = true;
+ [this.lastX, this.lastY] = [e.offsetX, e.offsetY];
+ });
+
+ this.canvas.addEventListener('mousemove', (e) => {
+ if (!this.isDrawing) return;
+
+ const x1 = this.lastX;
+ const y1 = this.lastY;
+ const x2 = e.offsetX;
+ const y2 = e.offsetY;
+ const color = this.colorPicker.value;
+ const lineWidth = 3;
+
+ // Dessiner localement
+ this.drawLine(x1, y1, x2, y2, color, lineWidth);
+
+ // Envoyer aux autres joueurs via WebSocket
+ if (this.socket?.connected) {
+ this.socket.emit('game-draw', { x1, y1, x2, y2, color, lineWidth });
+ }
+
+ [this.lastX, this.lastY] = [x2, y2];
+ });
+
+ this.canvas.addEventListener('mouseup', () => this.isDrawing = false);
+ this.canvas.addEventListener('mouseout', () => this.isDrawing = false);
+
+ this.clearCanvasBtn.addEventListener('click', () => {
+ this.clearCanvas();
+ // Notifier les autres
+ if (this.socket?.connected) {
+ this.socket.emit('game-clear-canvas');
+ }
+ });
+ }
+
+ drawLine(x1, y1, x2, y2, color, lineWidth) {
+ this.ctx.beginPath();
+ this.ctx.strokeStyle = color;
+ this.ctx.lineWidth = lineWidth;
+ this.ctx.lineCap = 'round';
+ this.ctx.moveTo(x1, y1);
+ this.ctx.lineTo(x2, y2);
+ this.ctx.stroke();
+ }
+
+ clearCanvas() {
+ this.ctx.fillStyle = '#333';
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+ }
+
+ bindEvents() {
+ this.tabs.addEventListener('click', (e) => {
+ const tab = e.target.closest(`.${CSS.GAMEROOM_TAB}`);
+ if (tab) {
+ this.switchTab(tab.dataset.tab);
+ }
+ });
+
+ this.createBtn.addEventListener('click', () => this.createRoom());
+ this.roomNameInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') this.createRoom();
+ });
+
+ this.leaveBtn.addEventListener('click', () => this.leaveRoom());
+ this.startGameBtn.addEventListener('click', () => this.startGame());
+
+ // Events du jeu
+ this.confirmWordBtn.addEventListener('click', () => this.confirmWord());
+ this.wordInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') this.confirmWord();
+ });
+
+ this.guessBtn.addEventListener('click', () => this.makeGuess());
+ this.letterInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') this.makeGuess();
+ });
+
+ this.backToLobbyBtn.addEventListener('click', () => this.backToLobby());
+ this.endRoundBtn.addEventListener('click', () => this.endGame());
+ }
+
+ // ============================================
+ // SOCKET.IO CONNECTION
+ // ============================================
+
+ async connectToGameSocket() {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token || !this.currentRoom) return;
+
+ // Ensure socket is connected
+ await this.ensureSocketConnected();
+
+ // Join the room
+ if (this.socket?.connected) {
+ console.log('Socket connected, joining room:', this.currentRoom.id);
+ this.socket.emit('game-join-room', { roomId: this.currentRoom.id });
+ }
+ }
+
+ async loadSocketIO() {
+ if (window.io) return;
+
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = '/socket.io/socket.io.js';
+
+ script.onload = () => {
+ console.log('Socket.IO loaded for game');
+ resolve();
+ };
+
+ script.onerror = () => {
+ console.error('Failed to load Socket.IO');
+ reject(new Error('Socket.IO load failed'));
+ };
+
+ document.head.appendChild(script);
+ });
+ }
+
+ setupGameSocketListeners() {
+ this.socketReady = false;
+
+ this.socket.on('connect', () => {
+ console.log('Game socket connected, id:', this.socket.id);
+ if (this.currentRoom) {
+ console.log('Joining room:', this.currentRoom.id);
+ this.socket.emit('game-join-room', { roomId: this.currentRoom.id });
+ }
+ });
+
+ this.socket.on('connect_error', (err) => {
+ console.error('Game socket connection error:', err.message);
+ });
+
+ // Confirmation that we joined the room
+ this.socket.on('game-room-joined', (data) => {
+ console.log('Successfully joined game room:', data.roomId);
+ this.socketReady = true;
+ });
+
+ // Real-time rooms list update
+ this.socket.on('game-rooms-updated', (data) => {
+ console.log('Rooms list updated:', data.rooms?.length, 'rooms');
+ if (this.currentTab === 'browse') {
+ this.roomsList = data.rooms || [];
+ this.renderRoomsList(this.roomsList);
+ }
+ });
+
+ // Real-time player list update in lobby
+ this.socket.on('game-players-updated', (data) => {
+ console.log('Players list updated:', data.players?.length, 'players');
+ if (this.currentRoom) {
+ this.renderPlayersList(data.players || []);
+ }
+ });
+
+ // Player joined/left
+ this.socket.on('game-player-joined', (data) => {
+ console.log(`${data.username} joined the room`);
+ });
+
+ this.socket.on('game-player-left', (data) => {
+ this.showMessage(`${data.username} a quitté le salon`, 'info');
+ console.log(`${data.username} joined the room`);
+
+ if (this.gameState.isPlaying)
+ {
+ if (this.gameState.players)
+ this.gameState.players = this.gameState.players.filter(p => p !== data.username);
+ }
+
+ if (this.gameState.scores)
+ {
+ delete this.gameState.scores[data.username];
+ this.updateScoresDisplay(this.gameState.scores);
+ }
+
+ if (this.gameState.drawer === data.username)
+ {
+ this.showMessage('Le dessinateur a quitté la partie, fin du jeu', 'info');
+ }
+
+ if (this.currentRoom && !this.gameState.isPlaying)
+ this.loadLobby();
+ });
+
+ // Game started
+ this.socket.on('game-started', (data) => {
+ console.log('Received game-started event:', data);
+ this.gameState.isPlaying = true;
+ this.gameState.drawer = data.drawer;
+ this.gameState.players = data.players;
+ this.gameState.currentPlayerIndex = data.players.indexOf(data.drawer);
+
+ // Initialize scores
+ this.gameState.scores = {};
+ data.players.forEach(p => this.gameState.scores[p] = 0);
+
+ this.showGameUI();
+ this.setupRound();
+ });
+
+ // Game start error
+ this.socket.on('game-start-error', (data) => {
+ console.error('Game start error:', data.error);
+ this.showMessage(data.error || 'Impossible de démarrer la partie', 'error');
+ });
+
+ // Word was set by drawer
+ this.socket.on('game-word-set', (data) => {
+ console.log(`Word set by ${data.drawer}, length: ${data.wordLength}`);
+ this.gameState.wordLength = data.wordLength;
+ this.gameState.revealedLetters = new Array(data.wordLength).fill(false);
+ this.gameState.revealedWord = data.revealedWord || new Array(data.wordLength).fill('_');
+
+ if (data.scores) {
+ this.updateScoresDisplay(data.scores);
+ }
+
+ this.updateWordDisplay();
+
+ // Don't change UI for spectators
+ if (this.isSpectating) {
+ this.currentDrawerInfo.textContent = '👁️ MODE SPECTATEUR - Vous regardez la partie';
+ return;
+ }
+
+ this.currentDrawerInfo.textContent = `${data.drawer} dessine (${data.wordLength} lettres)`;
+
+ // Enable guess input for non-drawers
+ if (!this.isCurrentUserDrawer()) {
+ this.guessContainer.style.display = 'flex';
+ this.letterInput.disabled = false;
+ this.guessBtn.disabled = false;
+ this.letterInput.placeholder = 'Proposez une lettre ou le mot...';
+ this.letterInput.focus();
+ }
+ });
+
+ // Drawing received
+ this.socket.on('game-draw', (data) => {
+ this.drawLine(data.x1, data.y1, data.x2, data.y2, data.color, data.lineWidth);
+ });
+
+ // Clear canvas
+ this.socket.on('game-clear-canvas', () => {
+ this.clearCanvas();
+ });
+
+ // Guess result
+ this.socket.on('game-guess-result', (data) => {
+ this.addGuessToHistory(data.guess, data.success, data.type, data.username, data.points || 0);
+
+ if (data.revealedLetters) {
+ this.gameState.revealedLetters = data.revealedLetters;
+ }
+ if (data.revealedWord) {
+ this.gameState.revealedWord = data.revealedWord;
+ }
+ if (data.scores) {
+ this.updateScoresDisplay(data.scores);
+ }
+ this.updateWordDisplay();
+ });
+
+ // Word found
+ this.socket.on('game-word-found', (data) => {
+ if (data.scores) {
+ this.updateScoresDisplay(data.scores);
+ }
+ this.wordFound(data.word, data.winner, data.drawerBonus || 0);
+ });
+
+ // New round
+ this.socket.on('game-new-round', (data) => {
+ this.gameState.drawer = data.drawer;
+ this.gameState.currentPlayerIndex = this.gameState.players.indexOf(data.drawer);
+ this.setupRound();
+ });
+
+ // Game ended
+ this.socket.on('game-ended', () => {
+ // If spectating, return to spectator list
+ if (this.isSpectating) {
+ this.resetGameUI();
+ this.currentRoom = null;
+ this.isSpectating = false;
+ this.switchTab('spectator');
+ this.showMessage('La partie est terminée', 'info');
+ } else {
+ this.resetGameUI();
+ this.loadLobby();
+ }
+ });
+
+ // Game message from server
+ this.socket.on('game-message', (data) => {
+ this.showMessage(data.message, data.type || 'info');
+ });
+
+ // Sync state for late joiners
+ this.socket.on('game-state-sync', (data) => {
+ if (data.isPlaying) {
+ this.gameState.isPlaying = true;
+ this.gameState.drawer = data.drawer;
+ this.gameState.wordLength = data.wordLength;
+ this.gameState.revealedLetters = data.revealedLetters || [];
+ this.gameState.revealedWord = data.revealedWord || new Array(data.wordLength).fill('_');
+ this.gameState.players = data.players;
+ this.gameState.scores = data.scores || {};
+
+ this.showGameUI();
+ this.updateWordDisplay();
+
+ // Update scores display
+ if (data.scores) {
+ this.updateScoresDisplay(data.scores);
+ }
+
+ this.currentDrawerInfo.textContent = `${data.drawer} dessine (${data.wordLength} lettres)`;
+
+ // Don't enable input for spectators
+ if (this.isSpectating) {
+ this.guessContainer.style.display = 'none';
+ this.wordInputContainer.style.display = 'none';
+ this.drawTools.style.display = 'none';
+ this.currentDrawerInfo.textContent = '👁️ MODE SPECTATEUR - Vous regardez la partie';
+ } else if (!this.isCurrentUserDrawer()) {
+ this.guessContainer.style.display = 'flex';
+ if (data.wordLength > 0) {
+ this.letterInput.disabled = false;
+ this.guessBtn.disabled = false;
+ this.letterInput.placeholder = 'Proposez une lettre ou le mot...';
+ } else {
+ this.letterInput.disabled = true;
+ this.guessBtn.disabled = true;
+ this.letterInput.placeholder = 'En attente du mot...';
+ }
+ }
+ }
+ });
+
+ // Spectator events
+ this.socket.on('game-spectate-joined', (data) => {
+ console.log('Successfully joined as spectator:', data.roomId);
+ this.isSpectating = true;
+
+ // Prepare UI for spectating
+ this.spectatorList.style.display = 'none';
+ this.list.style.display = 'none';
+ this.createContainer.style.display = 'none';
+ this.lobbyContainer.style.display = 'flex';
+
+ // Hide lobby elements, keep game container for when state syncs
+ this.playerList.style.display = 'none';
+ this.lobbyButtons.style.display = 'none';
+ this.lobbyTitle.textContent = 'Mode Spectateur';
+
+ this.showMessage('Vous regardez la partie...', 'success');
+ // The game state will be synced via game-state-sync event
+ });
+
+ this.socket.on('game-spectate-error', (data) => {
+ console.error('Spectate error:', data.error);
+ this.showMessage(data.error || 'Impossible de regarder cette partie', 'error');
+ });
+
+ this.socket.on('game-spectator-joined', (data) => {
+ console.log(`Spectator ${data.username} joined`);
+ });
+
+ this.socket.on('game-spectator-left', (data) => {
+ console.log(`Spectator ${data.username} left`);
+ });
+ }
+
+ disconnectGameSocket() {
+ if (this.socket) {
+ if (this.isSpectating) {
+ this.socket.emit('game-leave-spectate');
+ } else {
+ this.socket.emit('game-leave-room');
+ }
+ }
+ }
+
+ // ============================================
+ // UI HELPERS
+ // ============================================
+
+ isLoggedIn() {
+ return !!localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ }
+
+ updateTabsAccess() {
+ const loggedIn = this.isLoggedIn();
+
+ this.createTab.disabled = !loggedIn;
+ this.createTab.style.opacity = loggedIn ? '1' : '0.5';
+ this.createTab.title = loggedIn ? '' : 'Connectez-vous pour creer un salon';
+
+ if (!loggedIn && this.currentTab === 'create') {
+ this.switchTab('browse');
+ }
+ }
+
+ handleLogout() {
+ this.disconnectGameSocket();
+ if (this.currentRoom) {
+ this.exitLobby();
+ }
+ this.updateTabsAccess();
+ if (this.currentTab !== 'browse') {
+ this.switchTab('browse');
+ }
+ }
+
+ switchTab(tabName) {
+ if (tabName === 'lobby' && !this.currentRoom) {
+ return;
+ }
+
+ if (tabName === 'create' && !this.isLoggedIn()) {
+ this.showMessage('Connectez-vous pour creer un salon', 'info');
+ return;
+ }
+
+ this.currentTab = tabName;
+
+ [this.browseTab, this.spectatorTab, this.createTab, this.lobbyTab].forEach(tab => {
+ tab.classList.toggle(CSS.GAMEROOM_TAB_ACTIVE, tab.dataset.tab === tabName);
+ });
+
+ this.createContainer.style.display = tabName === 'create' ? 'flex' : 'none';
+ this.lobbyContainer.style.display = tabName === 'lobby' ? 'flex' : 'none';
+ this.list.style.display = tabName === 'browse' ? 'flex' : 'none';
+ this.spectatorList.style.display = tabName === 'spectator' ? 'flex' : 'none';
+
+ this.loadCurrentTab();
+ }
+
+ loadCurrentTab() {
+ switch (this.currentTab) {
+ case 'browse':
+ this.loadRooms();
+ // Connect to socket to receive real-time room updates
+ this.ensureSocketConnected();
+ break;
+ case 'spectator':
+ this.loadPlayingRooms();
+ this.ensureSocketConnected();
+ break;
+ case 'create':
+ this.message.textContent = '';
+ this.ensureSocketConnected();
+ break;
+ case 'lobby':
+ if (this.currentRoom) {
+ this.loadLobby();
+ }
+ break;
+ }
+ }
+
+ async ensureSocketConnected() {
+ if (!this.isLoggedIn()) return;
+ if (this.socket?.connected) return;
+
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token) return;
+
+ await this.loadSocketIO();
+
+ const ioConfig = {
+ auth: { token },
+ reconnection: true,
+ reconnectionAttempts: 5,
+ reconnectionDelay: 1000,
+ transports: ['websocket', 'polling']
+ };
+
+ const altPort = window.GLOBAL_CHAT_ALT_PORT;
+ if (altPort) {
+ const host = location.hostname || 'localhost';
+ this.socket = io(`http://${host}:${altPort}`, ioConfig);
+ } else {
+ this.socket = io(ioConfig);
+ }
+
+ this.setupGameSocketListeners();
+ }
+
+ getHeaders() {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ return {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json'
+ };
+ }
+
+ async loadRooms() {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token) {
+ this.showMessage('Connectez-vous pour voir les salons', 'info');
+ return;
+ }
+
+ try {
+ const response = await fetch(API.ROOMS.LIST, {
+ headers: this.getHeaders()
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.showMessage(data.error || 'Erreur', 'error');
+ return;
+ }
+
+ this.roomsList = data || [];
+ this.renderRoomsList(this.roomsList);
+ } catch (error) {
+ console.error('Load rooms error:', error);
+ this.showMessage('Erreur de connexion', 'error');
+ }
+ }
+
+ async checkCurrentRoom() {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token) {
+ return null;
+ }
+
+ try {
+ const response = await fetch(API.ROOMS.CURRENT, {
+ headers: this.getHeaders()
+ });
+
+ // 204 No Content means user is not in any room
+ if (response.status === 204) {
+ return null;
+ }
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const data = await response.json();
+ if (data && data.id) {
+ this.currentRoom = data;
+ this.enterLobby(data);
+ return data;
+ }
+ return null;
+ } catch (error) {
+ console.error('Check current room error:', error);
+ return null;
+ }
+ }
+
+ roomNameExists(name) {
+ const normalizedName = name.toLowerCase().trim();
+ return this.roomsList.some(room => room.name.toLowerCase().trim() === normalizedName);
+ }
+
+ renderRoomsList(rooms) {
+ this.list.innerHTML = '';
+ this.message.textContent = '';
+
+ if (rooms.length === 0) {
+ this.showMessage('Aucun salon disponible', 'info');
+ return;
+ }
+
+ rooms.forEach(room => {
+ const item = this.createRoomItem(room);
+ this.list.appendChild(item);
+ });
+ }
+
+ createRoomItem(room) {
+ const item = this.createElement('div', CSS.GAMEROOM_ITEM);
+
+ const name = this.createElement('span', CSS.GAMEROOM_NAME, {
+ text: room.name
+ });
+
+ const players = this.createElement('span', CSS.GAMEROOM_PLAYERS, {
+ text: `${room.player_count || 0}/${room.max_players || 8}`
+ });
+
+ const actions = this.createElement('div', CSS.GAMEROOM_ACTIONS);
+
+ const joinBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SUCCESS], {
+ text: 'Rejoindre'
+ });
+ joinBtn.addEventListener('click', () => this.joinRoom(room.id));
+ actions.appendChild(joinBtn);
+
+ item.append(name, players, actions);
+ return item;
+ }
+
+ createSpectatorRoomItem(room) {
+ const item = this.createElement('div', CSS.GAMEROOM_ITEM);
+
+ const name = this.createElement('span', CSS.GAMEROOM_NAME, {
+ text: room.name
+ });
+
+ const players = this.createElement('span', CSS.GAMEROOM_PLAYERS, {
+ text: `${room.player_count || 0}/${room.max_players || 8}`
+ });
+
+ const status = this.createElement('span', 'gameroom__status', {
+ text: '🎮 En cours'
+ });
+ status.style.color = '#4CAF50';
+ status.style.fontWeight = 'bold';
+
+ const actions = this.createElement('div', CSS.GAMEROOM_ACTIONS);
+
+ const spectateBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
+ text: 'Regarder'
+ });
+ spectateBtn.addEventListener('click', () => this.spectateRoom(room.id));
+ actions.appendChild(spectateBtn);
+
+ item.append(name, players, status, actions);
+ return item;
+ }
+
+ async loadPlayingRooms() {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token) {
+ this.showMessage('Connectez-vous pour voir les parties en cours', 'info');
+ return;
+ }
+
+ try {
+ const response = await fetch(API.ROOMS.PLAYING, {
+ headers: this.getHeaders()
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.showMessage(data.error || 'Erreur', 'error');
+ return;
+ }
+
+ this.renderPlayingRoomsList(data || []);
+ } catch (error) {
+ console.error('Load playing rooms error:', error);
+ this.showMessage('Erreur de connexion', 'error');
+ }
+ }
+
+ renderPlayingRoomsList(rooms) {
+ this.spectatorList.innerHTML = '';
+ this.message.textContent = '';
+
+ if (rooms.length === 0) {
+ this.showMessage('Aucune partie en cours', 'info');
+ return;
+ }
+
+ rooms.forEach(room => {
+ const item = this.createSpectatorRoomItem(room);
+ this.spectatorList.appendChild(item);
+ });
+ }
+
+ async spectateRoom(roomId) {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token) {
+ this.showMessage('Connectez-vous pour regarder', 'info');
+ return;
+ }
+
+ // Check if user is already in a room as a player
+ if (this.currentRoom && !this.isSpectating) {
+ this.showMessage('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'error');
+ return;
+ }
+
+ // Check if already spectating another game
+ if (this.isSpectating && this.currentRoom && this.currentRoom.id !== roomId) {
+ this.showMessage('Vous regardez déjà une autre partie', 'error');
+ return;
+ }
+
+ try {
+ const response = await fetch(API.ROOMS.SPECTATE(roomId), {
+ method: 'POST',
+ headers: this.getHeaders()
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.showMessage(data.error || 'Impossible de regarder cette partie', 'error');
+ return;
+ }
+
+ // Store room info and mark as spectating
+ this.currentRoom = data;
+ this.isSpectating = true;
+
+ // Join as spectator via socket
+ await this.ensureSocketConnected();
+ if (this.socket?.connected) {
+ this.socket.emit('game-spectate-room', { roomId: roomId });
+ }
+
+ this.showMessage('Connexion à la partie...', 'info');
+ } catch (error) {
+ console.error('Spectate room error:', error);
+ this.showMessage('Erreur de connexion', 'error');
+ }
+ }
+
+ async createRoom() {
+ const name = this.roomNameInput.value.trim();
+ if (!name) {
+ this.showMessage('Entrez un nom pour le salon', 'error');
+ return;
+ }
+
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token) {
+ this.showMessage('Connectez-vous pour creer un salon', 'info');
+ return;
+ }
+
+ if (this.currentRoom) {
+ this.showMessage('Vous etes deja dans un salon. Quittez-le d\'abord.', 'error');
+ return;
+ }
+
+ try {
+ const currentResponse = await fetch(API.ROOMS.CURRENT, {
+ headers: this.getHeaders()
+ });
+ if (currentResponse.ok && currentResponse.status !== 204) {
+ const currentData = await currentResponse.json();
+ if (currentData && currentData.id) {
+ this.currentRoom = currentData;
+ this.enterLobby(currentData);
+ this.showMessage('Vous etes deja dans un salon', 'error');
+ return;
+ }
+ }
+ } catch (e) {
+ // Continue
+ }
+
+ try {
+ const listResponse = await fetch(API.ROOMS.LIST, {
+ headers: this.getHeaders()
+ });
+ if (listResponse.ok) {
+ this.roomsList = await listResponse.json() || [];
+ }
+ } catch (e) {
+ // Continue
+ }
+
+ if (this.roomNameExists(name)) {
+ this.showMessage('Un salon avec ce nom existe deja', 'error');
+ return;
+ }
+
+ try {
+ const response = await fetch(API.ROOMS.CREATE, {
+ method: 'POST',
+ headers: this.getHeaders(),
+ body: JSON.stringify({ name })
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.showMessage(data.error || 'Erreur', 'error');
+ return;
+ }
+
+ this.roomNameInput.value = '';
+ this.currentRoom = data;
+ this.showMessage('Salon cree', 'success');
+ eventBus.emit(Events.ROOM_CREATED, data);
+ this.enterLobby(data);
+ } catch (error) {
+ console.error('Create room error:', error);
+ this.showMessage('Erreur de connexion', 'error');
+ }
+ }
+
+ async joinRoom(roomId) {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token) {
+ this.showMessage('Connectez-vous pour rejoindre', 'info');
+ return;
+ }
+
+ if (this.currentRoom) {
+ this.showMessage('Vous etes deja dans un salon. Quittez-le d\'abord.', 'error');
+ return;
+ }
+
+ try {
+ const currentResponse = await fetch(API.ROOMS.CURRENT, {
+ headers: this.getHeaders()
+ });
+ if (currentResponse.ok && currentResponse.status !== 204) {
+ const currentData = await currentResponse.json();
+ if (currentData && currentData.id) {
+ this.currentRoom = currentData;
+ this.enterLobby(currentData);
+ this.showMessage('Vous etes deja dans un salon', 'error');
+ return;
+ }
+ }
+ } catch (e) {
+ // Continue
+ }
+
+ try {
+ const response = await fetch(API.ROOMS.JOIN(roomId), {
+ method: 'POST',
+ headers: this.getHeaders()
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.showMessage(data.error || 'Erreur', 'error');
+ return;
+ }
+
+ const roomResponse = await fetch(API.ROOMS.GET(roomId), {
+ headers: this.getHeaders()
+ });
+ const roomData = await roomResponse.json();
+
+ this.currentRoom = roomData;
+ eventBus.emit(Events.ROOM_JOINED, roomData);
+ this.enterLobby(roomData);
+ } catch (error) {
+ console.error('Join room error:', error);
+ this.showMessage('Erreur de connexion', 'error');
+ }
+ }
+
+ enterLobby(room) {
+ this.currentRoom = room;
+ this.lobbyTab.style.display = 'block';
+ this.lobbyTitle.textContent = room.name;
+ this.switchTab('lobby');
+
+ // Connect to WebSocket for real-time sync
+ this.connectToGameSocket();
+ }
+
+ async loadLobby() {
+ if (!this.currentRoom) return;
+
+ this.gameState.scores = {};
+
+ try {
+ const response = await fetch(API.ROOMS.PLAYERS(this.currentRoom.id), {
+ headers: this.getHeaders()
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.showMessage(data.error || 'Erreur', 'error');
+ return;
+ }
+
+ this.renderPlayersList(data || []);
+ } catch (error) {
+ console.error('Load lobby error:', error);
+ this.showMessage('Erreur de connexion', 'error');
+ }
+ }
+
+ renderPlayersList(players) {
+ this.playerList.innerHTML = '';
+
+ if (players.length === 0) {
+ const empty = this.createElement('div', 'gameroom__empty', {
+ text: 'Aucun joueur'
+ });
+ this.playerList.appendChild(empty);
+ // Disable start button if no players
+ this.startGameBtn.disabled = true;
+ this.startGameBtn.style.opacity = '0.5';
+ this.startGameBtn.title = 'Il faut au moins 2 joueurs';
+ return;
+ }
+
+ players.forEach(player => {
+ const item = this.createElement('div', CSS.GAMEROOM_PLAYER);
+
+ const avatar = this.createElement('img', CSS.GAMEROOM_PLAYER_AVATAR, {
+ alt: player.username
+ });
+ avatar.src = player.avatar_url || '/avatar/default.png';
+
+ const name = this.createElement('span', CSS.GAMEROOM_PLAYER_NAME, {
+ text: player.username
+ });
+
+ const statsContainer = this.createElement('div', 'gameroom__player-stats');
+
+ const score = this.createElement('span', CSS.GAMEROOM_PLAYER_SCORE, {
+ text: `${player.score || 0} pts`
+ });
+
+ const totalPoints = this.createElement('span', 'gameroom__player-total', {
+ text: `Total: ${player.total_points || 0}`
+ });
+
+ statsContainer.append(score, totalPoints);
+ item.append(avatar, name, statsContainer);
+ this.playerList.appendChild(item);
+ });
+
+ // Enable/disable start button based on player count
+ if (players.length < 2) {
+ this.startGameBtn.disabled = true;
+ this.startGameBtn.style.opacity = '0.5';
+ this.startGameBtn.title = 'Il faut au moins 2 joueurs';
+ } else {
+ this.startGameBtn.disabled = false;
+ this.startGameBtn.style.opacity = '1';
+ this.startGameBtn.title = '';
+ }
+ }
+
+ async leaveRoom() {
+ if (!this.currentRoom) return;
+
+ // End game if playing
+ if (this.gameState.isPlaying) {
+ this.endGame();
+ }
+
+ this.disconnectGameSocket();
+
+ try {
+ const response = await fetch(API.ROOMS.LEAVE(this.currentRoom.id), {
+ method: 'POST',
+ headers: this.getHeaders()
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ this.showMessage(data.error || 'Erreur', 'error');
+ return;
+ }
+
+ eventBus.emit(Events.ROOM_LEFT, this.currentRoom);
+ this.exitLobby();
+ } catch (error) {
+ console.error('Leave room error:', error);
+ this.showMessage('Erreur de connexion', 'error');
+ }
+ }
+
+ exitLobby() {
+ this.currentRoom = null;
+ this.lobbyTab.style.display = 'none';
+ this.playerList.innerHTML = '';
+ this.lobbyTitle.textContent = '';
+ this.resetGameUI();
+ this.switchTab('browse');
+ }
+
+ showMessage(text, type = 'info') {
+ // Clear any existing timeout
+ if (this.messageTimeout) {
+ clearTimeout(this.messageTimeout);
+ }
+
+ this.message.textContent = text;
+ this.message.className = CSS.MESSAGE;
+
+ if (type === 'success') {
+ this.message.classList.add(CSS.MESSAGE_SUCCESS);
+ } else if (type === 'error') {
+ this.message.classList.add(CSS.MESSAGE_ERROR);
+ } else {
+ this.message.classList.add(CSS.MESSAGE_INFO);
+ }
+
+ // Auto-clear message after 5 seconds
+ this.messageTimeout = setTimeout(() => {
+ this.message.textContent = '';
+ this.message.className = CSS.MESSAGE;
+ }, 5000);
+ }
+
+ // ============================================
+ // LOGIQUE DU JEU
+ // ============================================
+
+ getCurrentUsername() {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (!token) return null;
+ try {
+ const payload = JSON.parse(atob(token.split('.')[1]));
+ return payload.username || payload.sub || 'Joueur';
+ } catch {
+ return 'Joueur';
+ }
+ }
+
+ isCurrentUserDrawer() {
+ return this.gameState.drawer === this.getCurrentUsername();
+ }
+
+ showGameUI() {
+ this.gameContainer.style.display = 'flex';
+ this.playerList.style.display = 'none';
+ this.lobbyButtons.style.display = 'none';
+ this.clearCanvas();
+ this.guessHistory.innerHTML = '';
+
+ // If spectating, show indicator and disable interactions
+ if (this.isSpectating) {
+ this.currentDrawerInfo.textContent = '👁️ MODE SPECTATEUR - Vous regardez la partie';
+ this.currentDrawerInfo.style.backgroundColor = '#2196F3';
+ this.currentDrawerInfo.style.color = 'white';
+ this.currentDrawerInfo.style.padding = '8px';
+ this.currentDrawerInfo.style.borderRadius = '4px';
+ this.currentDrawerInfo.style.textAlign = 'center';
+
+ // Change button text for spectators
+ this.backToLobbyBtn.textContent = 'Arrêter de regarder';
+ this.endRoundBtn.style.display = 'none'; // Hide end game button for spectators
+ } else {
+ this.backToLobbyBtn.textContent = 'Quitter la partie';
+ this.endRoundBtn.style.display = 'inline-block';
+ }
+ }
+
+ resetGameUI() {
+ this.gameState.isPlaying = false;
+ this.gameState.currentWord = '';
+ this.gameState.wordLength = 0;
+ this.gameState.revealedLetters = [];
+ this.gameState.revealedWord = [];
+ this.gameState.drawer = null;
+ this.isSpectating = false;
+
+ this.gameState.scores = {};
+ this.gameState.players = [];
+ this.gameState.currentPlayerIndex = 0;
+ this.gameState.guessedLetters = [];
+
+ // Clear scores display
+ if (this.scoresDisplay)
+ this.scoresDisplay.textContent = '';
+
+ if (this.guessHistory)
+ this.guessHistory.innerHTML = '';
+
+ this.clearCanvas();
+
+ this.gameContainer.style.display = 'none';
+ this.playerList.style.display = 'flex';
+ this.lobbyButtons.style.display = 'flex';
+
+ this.wordInputContainer.style.display = 'none';
+ this.guessContainer.style.display = 'none';
+ this.drawTools.style.display = 'none';
+
+ // Reset spectator styling
+ this.currentDrawerInfo.style.backgroundColor = '';
+ this.currentDrawerInfo.style.color = '';
+ this.currentDrawerInfo.style.padding = '';
+ this.currentDrawerInfo.style.borderRadius = '';
+ this.currentDrawerInfo.style.textAlign = '';
+ this.currentDrawerInfo.classList.remove('gameroom__drawer-info--winner');
+ }
+
+ async startGame() {
+ console.log('startGame called');
+
+ // Load player list
+ await this.loadLobby();
+
+ const playerElements = this.playerList.querySelectorAll('.gameroom__player-name');
+ const players = Array.from(playerElements).map(el => el.textContent);
+
+ console.log('Players found:', players);
+
+ if (players.length < 2) {
+ this.showMessage('Il faut au moins 2 joueurs pour commencer', 'error');
+ return;
+ }
+
+ const drawer = players[0];
+
+ console.log('Socket connected:', this.socket?.connected, 'Socket ready:', this.socketReady);
+
+ // Send start game event via WebSocket
+ if (this.socket?.connected) {
+ console.log('Emitting game-start event');
+ this.socket.emit('game-start', { drawer, players });
+ } else {
+ console.log('No socket, using local fallback');
+ // Fallback local - start immediately
+ this.gameState.isPlaying = true;
+ this.gameState.players = players;
+ this.gameState.drawer = drawer;
+ this.gameState.currentPlayerIndex = 0;
+ this.showGameUI();
+ this.setupRound();
+ }
+ }
+
+ setupRound() {
+ this.gameState.currentWord = '';
+ this.gameState.wordLength = 0;
+ this.gameState.revealedLetters = [];
+ this.gameState.revealedWord = [];
+ this.gameState.guessedLetters = [];
+
+ this.currentDrawerInfo.textContent = `C'est au tour de ${this.gameState.drawer} de dessiner`;
+ this.currentDrawerInfo.classList.remove('gameroom__drawer-info--winner');
+ this.wordDisplay.textContent = '';
+ this.guessHistory.innerHTML = '';
+ this.clearCanvas();
+
+ // Spectators cannot interact
+ if (this.isSpectating) {
+ this.wordInputContainer.style.display = 'none';
+ this.guessContainer.style.display = 'none';
+ this.drawTools.style.display = 'none';
+ this.currentDrawerInfo.textContent = '👁️ MODE SPECTATEUR - Vous regardez la partie';
+ return;
+ }
+
+ if (this.isCurrentUserDrawer()) {
+ // Drawer chooses a word
+ this.wordInputContainer.style.display = 'flex';
+ this.guessContainer.style.display = 'none';
+ this.drawTools.style.display = 'none';
+ this.currentDrawerInfo.textContent = 'Choisissez un mot a faire deviner';
+ } else {
+ // Others see the guess input (disabled while waiting for word)
+ this.wordInputContainer.style.display = 'none';
+ this.guessContainer.style.display = 'flex';
+ this.drawTools.style.display = 'none';
+ this.letterInput.disabled = true;
+ this.guessBtn.disabled = true;
+ this.letterInput.placeholder = 'En attente du mot...';
+ this.currentDrawerInfo.textContent = `${this.gameState.drawer} choisit un mot...`;
+ }
+ }
+
+ confirmWord() {
+ const word = this.wordInput.value.trim().toLowerCase();
+ if (!word || word.length < 2) {
+ this.showMessage('Le mot doit faire au moins 2 lettres', 'error');
+ return;
+ }
+
+ if (!/^[a-z]+$/.test(word)) {
+ this.showMessage('Le mot ne doit contenir que des lettres', 'error');
+ return;
+ }
+
+ this.gameState.currentWord = word;
+ this.gameState.wordLength = word.length;
+ this.gameState.revealedLetters = new Array(word.length).fill(false);
+ this.gameState.revealedWord = new Array(word.length).fill('_');
+
+ this.wordInput.value = '';
+ this.wordInputContainer.style.display = 'none';
+ this.drawTools.style.display = 'flex';
+
+ // Send word to server via WebSocket
+ if (this.socket?.connected) {
+ this.socket.emit('game-set-word', { word });
+ }
+
+ this.updateWordDisplay();
+ this.currentDrawerInfo.textContent = `Dessinez pour faire deviner le mot (${word.length} lettres)`;
+ }
+
+ updateWordDisplay() {
+ // If drawer, show from currentWord
+ if (this.isCurrentUserDrawer() && this.gameState.currentWord) {
+ let display = '';
+ for (let i = 0; i < this.gameState.currentWord.length; i++) {
+ if (this.gameState.revealedLetters && this.gameState.revealedLetters[i]) {
+ display += this.gameState.currentWord[i] + ' ';
+ } else {
+ display += '_ ';
+ }
+ }
+ this.wordDisplay.textContent = display.trim();
+ return;
+ }
+
+ // For guessers, use revealedWord from server
+ if (this.gameState.revealedWord && this.gameState.revealedWord.length > 0) {
+ this.wordDisplay.textContent = this.gameState.revealedWord.join(' ');
+ return;
+ }
+
+ // Fallback: show underscores based on wordLength
+ if (this.gameState.wordLength > 0) {
+ this.wordDisplay.textContent = '_ '.repeat(this.gameState.wordLength).trim();
+ }
+ }
+
+ makeGuess() {
+ const guess = this.letterInput.value.trim().toLowerCase();
+ if (!guess) return;
+
+ this.letterInput.value = '';
+
+ // Send guess via WebSocket
+ if (this.socket?.connected) {
+ this.socket.emit('game-guess', { guess });
+ } else {
+ // Fallback local (for testing)
+ this.processGuessLocally(guess);
+ }
+ }
+
+ processGuessLocally(guess) {
+ const username = this.getCurrentUsername();
+
+ if (guess.length > 1) {
+ const success = guess === this.gameState.currentWord;
+ this.addGuessToHistory(guess, success, 'word', username);
+ if (success) {
+ this.gameState.revealedWord = this.gameState.currentWord.split('');
+ this.wordFound(this.gameState.currentWord, username);
+ }
+ return;
+ }
+
+ if (this.gameState.guessedLetters.includes(guess)) {
+ this.showMessage('Lettre deja proposee', 'info');
+ return;
+ }
+
+ this.gameState.guessedLetters.push(guess);
+
+ let found = false;
+ for (let i = 0; i < this.gameState.currentWord.length; i++) {
+ if (this.gameState.currentWord[i] === guess) {
+ this.gameState.revealedLetters[i] = true;
+ this.gameState.revealedWord[i] = guess;
+ found = true;
+ }
+ }
+
+ this.addGuessToHistory(guess, found, 'letter', username);
+ this.updateWordDisplay();
+
+ if (this.gameState.revealedLetters.every(r => r)) {
+ this.wordFound(this.gameState.currentWord, username);
+ }
+ }
+
+ addGuessToHistory(guess, success, type, username, points = 0) {
+ const item = this.createElement('div', 'gameroom__guess-item');
+ item.classList.add(success ? 'gameroom__guess-item--success' : 'gameroom__guess-item--fail');
+
+ const typeText = type === 'letter' ? 'lettre' : 'mot';
+ const pointsText = points !== 0 ? ` (${points > 0 ? '+' : ''}${points} pts)` : '';
+
+ if (success) {
+ item.textContent = `${username}: "${guess}" - Bon ${typeText}!${pointsText}`;
+ } else {
+ item.textContent = `${username}: "${guess}" - Mauvais ${typeText}${pointsText}`;
+ }
+
+ this.guessHistory.appendChild(item);
+ this.guessHistory.scrollTop = this.guessHistory.scrollHeight;
+ }
+
+ updateScoresDisplay(scores) {
+ if (!scores) return;
+ this.gameState.scores = scores;
+
+ // Update scores display in game UI
+ if (this.scoresDisplay) {
+ const sortedScores = Object.entries(scores)
+ .sort((a, b) => b[1] - a[1])
+ .map(([name, score]) => `${name}: ${score}`)
+ .join(' | ');
+ this.scoresDisplay.textContent = sortedScores;
+ }
+
+ // Update player list with scores if visible
+ const playerItems = this.playerList.querySelectorAll('.gameroom__player');
+ playerItems.forEach(item => {
+ const nameEl = item.querySelector('.gameroom__player-name');
+ const scoreEl = item.querySelector('.gameroom__player-score');
+ if (nameEl && scoreEl) {
+ const playerName = nameEl.textContent;
+ const score = scores[playerName] || 0;
+ scoreEl.textContent = `${score} pts`;
+ }
+ });
+ }
+
+ wordFound(word, winner, drawerBonus = 0) {
+ let message = `${winner} a trouve le mot: ${word}!`;
+ if (drawerBonus > 0 && this.gameState.drawer) {
+ message += ` (${this.gameState.drawer} +${drawerBonus} pts)`;
+ }
+ this.currentDrawerInfo.textContent = message;
+ this.currentDrawerInfo.classList.add('gameroom__drawer-info--winner');
+
+ this.guessContainer.style.display = 'none';
+ this.drawTools.style.display = 'none';
+
+ // Reveal full word
+ this.wordDisplay.textContent = word.split('').join(' ');
+
+ // Auto next round after delay
+ setTimeout(() => {
+ if (this.gameState.isPlaying) {
+ this.nextRound();
+ }
+ }, 3000);
+ }
+
+ nextRound() {
+ // Move to next player
+ this.gameState.currentPlayerIndex = (this.gameState.currentPlayerIndex + 1) % this.gameState.players.length;
+ const nextDrawer = this.gameState.players[this.gameState.currentPlayerIndex];
+
+ if (this.socket?.connected) {
+ this.socket.emit('game-next-round', { drawer: nextDrawer });
+ } else {
+ this.gameState.drawer = nextDrawer;
+ this.setupRound();
+ }
+ }
+
+ backToLobby() {
+ if (this.socket?.connected) {
+ this.socket.emit('leave-room-during-game');
+ }
+
+ // Return to lobby without ending game for others
+ this.resetGameUI();
+ this.exitLobby();
+ this.showMessage('Vous avez quitté la partie', 'info');
+ }
+
+ endGame() {
+ if (this.socket?.connected) {
+ this.socket.emit('game-end');
+ }
+ this.resetGameUI();
+ this.showMessage('Jeu termine', 'info');
+
+ }
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/global_chat.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/global_chat.js
new file mode 100755
index 0000000..2936321
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/global_chat.js
@@ -0,0 +1,320 @@
+import { Window } from './windows.js';
+import { STORAGE_KEYS, CSS } from './config.js';
+import { eventBus, Events } from './events.js';
+
+/**
+ * Global chat window
+ * Uses Socket.IO for real-time communication
+ */
+export class GlobalChat extends Window {
+ constructor() {
+ super({
+ name: 'chat',
+ title: 'Global Chat',
+ cssClasses: ['chat']
+ });
+
+ this.socket = null;
+ this.connected = false;
+ this.friendIds = new Set();
+ this.currentUserId = null;
+ this.currentUsername = null;
+
+ this.buildUI();
+ this.bindEvents();
+ }
+
+ /**
+ * Builds the user interface
+ */
+ buildUI() {
+ // Message display area
+ this.output = this.createElement('div', CSS.CHAT_OUTPUT);
+
+ // Input container
+ this.inputContainer = this.createElement('div', 'chat__input-container');
+
+ this.input = this.createElement('input', [CSS.INPUT, CSS.CHAT_INPUT], {
+ type: 'text',
+ placeholder: 'Type your message...'
+ });
+
+ this.sendBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
+ text: 'Send'
+ });
+
+ this.inputContainer.append(this.input, this.sendBtn);
+
+ // Connection controls
+ this.controls = this.createElement('div', CSS.CHAT_CONTROLS);
+
+ this.connectBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SUCCESS], {
+ text: 'Connect'
+ });
+
+ this.reconnectBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
+ text: 'Reconnect'
+ });
+
+ this.controls.append(this.connectBtn, this.reconnectBtn);
+
+ // Assembly
+ this.body.append(this.output, this.inputContainer, this.controls);
+ }
+
+ /**
+ * Attaches event handlers
+ */
+ bindEvents() {
+ this.sendBtn.addEventListener('click', () => this.sendMessage());
+ this.connectBtn.addEventListener('click', () => this.connect());
+ this.reconnectBtn.addEventListener('click', () => this.reconnect());
+
+ // Send with Enter
+ this.input.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ this.sendMessage();
+ }
+ });
+ }
+
+ /**
+ * Displays a system message
+ * @param {string} text - Message text
+ * @param {'info'|'error'|'success'} type - Message type
+ */
+ addSystemMessage(text, type = 'info') {
+ const msg = this.createElement('div', CSS.CHAT_SYSTEM);
+
+ if (type === 'error') {
+ msg.classList.add('chat__system--error');
+ } else if (type === 'success') {
+ msg.classList.add('chat__system--success');
+ }
+
+ msg.textContent = text;
+ this.output.appendChild(msg);
+ this.scrollToBottom();
+ }
+
+ /**
+ * Displays a chat message
+ * @param {string} username - User name
+ * @param {string} content - Message content
+ * @param {boolean} isOwn - Is this the current user's message
+ * @param {boolean} isFriend - Is this user a friend
+ */
+ addChatMessage(username, content, isOwn = false, isFriend = false) {
+ const msg = this.createElement('div', CSS.CHAT_MESSAGE);
+
+ if (isOwn) {
+ msg.classList.add('chat__message--own');
+ }
+
+ const friendIndicator = isFriend ? '' : '';
+ msg.innerHTML = `${friendIndicator}${this.escapeHtml(username)}: ${this.escapeHtml(content)}`;
+ this.output.appendChild(msg);
+ this.scrollToBottom();
+ }
+
+ /**
+ * Escapes HTML to prevent XSS
+ * @param {string} text
+ * @returns {string}
+ */
+ escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ /**
+ * Scrolls the message area to the bottom
+ */
+ scrollToBottom() {
+ this.output.scrollTop = this.output.scrollHeight;
+ }
+
+ /**
+ * Sends a message
+ */
+ sendMessage() {
+ const content = this.input.value.trim();
+ if (!content) return;
+
+ if (!this.socket?.connected) {
+ this.addSystemMessage('Error: you are not connected to the global chat', 'error');
+ return;
+ }
+
+ this.socket.emit('chat-message', { content });
+ this.addChatMessage('Me', content, true);
+ this.input.value = '';
+ }
+
+ /**
+ * Reconnects to the server
+ */
+ async reconnect() {
+ if (this.socket) {
+ try {
+ this.socket.close();
+ } catch (e) {
+ // Ignore
+ }
+ this.socket = null;
+ }
+
+ this.connected = false;
+ this.addSystemMessage('Reconnecting...');
+ await this.connect();
+ }
+
+ decodeToken(token)
+ {
+ try
+ {
+ const payload = token.split('.')[1];
+ return (JSON.parse(atob(payload)));
+ }
+ catch
+ {
+ return (null);
+ }
+ }
+
+ /**
+ * Connects to the Socket.IO server
+ */
+ async connect() {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+
+ if (!token) {
+ this.addSystemMessage('Error: you must be logged in to use the global chat', 'error');
+ return;
+ }
+
+ const tokenData = this.decodeToken(token);
+
+ if (tokenData) {
+ this.currentUserId = tokenData.id || tokenData.userId || tokenData.user_id || tokenData.sub || null;
+ this.currentUsername = tokenData.username || tokenData.name || null;
+ }
+
+ if (this.socket?.connected) {
+ this.addSystemMessage('Already connected to global chat');
+ return;
+ }
+
+ // Load Socket.IO if needed
+ await this.loadSocketIO();
+
+ const ioConfig = {
+ auth: { token },
+ reconnection: true,
+ reconnectionAttempts: 5,
+ reconnectionDelay: 1000,
+ transports: ['websocket', 'polling']
+ };
+
+ // Optional alternative port
+ const altPort = window.GLOBAL_CHAT_ALT_PORT;
+ if (altPort) {
+ const host = location.hostname || 'localhost';
+ this.socket = io(`http://${host}:${altPort}`, ioConfig);
+ } else {
+ this.socket = io(ioConfig);
+ }
+
+ this.setupSocketListeners();
+ }
+
+ /**
+ * Loads the Socket.IO script if needed
+ */
+ async loadSocketIO() {
+ if (window.io) return;
+
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = '/socket.io/socket.io.js';
+
+ script.onload = () => {
+ console.log('Socket.IO loaded');
+ resolve();
+ };
+
+ script.onerror = () => {
+ console.error('Failed to load Socket.IO');
+ reject(new Error('Socket.IO load failed'));
+ };
+
+ document.head.appendChild(script);
+ });
+ }
+
+ /**
+ * Sets up Socket.IO listeners
+ */
+ setupSocketListeners() {
+ this.socket.on('connect', () => {
+ console.log('Socket connected, ID:', this.socket.id);
+ this.connected = true;
+ this.output.innerHTML = '';
+ this.addSystemMessage('Connected to global chat', 'success');
+ eventBus.emit(Events.CHAT_CONNECTED, { socketId: this.socket.id });
+ });
+
+ this.socket.on('connect_error', (err) => {
+ console.error('Socket connection error:', err.message);
+ this.addSystemMessage(`Connection error: ${err.message}`, 'error');
+ });
+
+ this.socket.on('disconnect', (reason) => {
+ console.log('Socket disconnected:', reason);
+ this.connected = false;
+ this.addSystemMessage(`Disconnected (${reason})`);
+ eventBus.emit(Events.CHAT_DISCONNECTED, { reason });
+ });
+
+ this.socket.on('chat-init', (data) => {
+ this.friendIds = new Set(data.friendIds || []);
+
+ // Display recent messages
+ data.messages.forEach(msg => {
+ const isOwn = this.isOwnMessage(msg);
+ const isFriend = !isOwn && this.friendIds.has(msg.sender_id);
+ const displayUsername = isOwn ? 'Me' : msg.username;
+ this.addChatMessage(displayUsername, msg.content, isOwn, isFriend);
+ });
+ });
+
+ this.socket.on('chat-message', (msg) => {
+ const isOwn = this.isOwnMessage(msg);
+ if (isOwn)
+ return;
+
+ const isFriend = this.friendIds.has(msg.sender_id);
+ this.addChatMessage(msg.username, msg.content, false, isFriend);
+ eventBus.emit(Events.CHAT_MESSAGE_RECEIVED, msg);
+ });
+ }
+
+ isOwnMessage(msg)
+ {
+ if (this.currentUserId !== null && msg.sender_id !== undefined && msg.sender_id !== null)
+ {
+ if (String(this.currentUserId) === String(msg.sender_id))
+ return (true);
+ }
+
+ if (this.currentUsername && msg.username)
+ {
+ if (this.currentUsername.toLowerCase() === msg.username.toLowerCase())
+ return (true);
+ }
+
+ return (false);
+ }
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/index.css b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/index.css
new file mode 100755
index 0000000..3e260a9
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/index.css
@@ -0,0 +1,682 @@
+/* ============================================
+ TRANSCENDENCE - Main Stylesheet
+ Convention: BEM (Block__Element--Modifier)
+ ============================================ */
+
+/* ============================================
+ CSS VARIABLES
+ ============================================ */
+:root {
+ --color-primary: #0066cc;
+ --color-primary-hover: #0052a3;
+ --color-success: #3cff01;
+ --color-success-dark: #28a745;
+ --color-error: #ff4d4d;
+ --color-warning: #ffc107;
+ --color-github: #24292e;
+
+ --color-bg: #a3a3a3;
+
+ --app-background-base: radial-gradient(
+ circle at top,
+ #000000,
+ #4d4d4d
+ );
+
+ --app-background-image: url("./assets/background.png");
+
+ --color-surface: #222;
+ --color-surface-light: #333;
+ --color-text: #fff;
+ --color-text-muted: #aaa;
+
+ --font-size-base: 10px;
+ --font-size-sm: 1.2rem;
+ --font-size-md: 1.4rem;
+ --font-size-lg: 1.6rem;
+ --font-size-xl: 3rem;
+
+ --spacing-xs: 4px;
+ --spacing-sm: 8px;
+ --spacing-md: 12px;
+ --spacing-lg: 16px;
+ --spacing-xl: 24px;
+
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 12px;
+ --radius-full: 50%;
+
+ --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.5);
+ --shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.5);
+
+ --transition-fast: 150ms ease;
+ --transition-normal: 250ms ease;
+
+ --z-menu: 2;
+ --z-window: 100;
+ --z-modal: 200;
+}
+
+/* ============================================
+ RESET & BASE
+ ============================================ */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ height: 100%;
+ background-image:
+ var(--app-background-image),
+ var(--app-background-base);
+
+ background-size:
+ contain,
+ cover;
+
+ background-position:
+ center,
+ center;
+
+ background-repeat:
+ no-repeat,
+ no-repeat;
+}
+
+body {
+ margin: 0;
+ width: 70%;
+ min-width: 800px;
+ margin: 0 auto;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ color: var(--color-text);
+ line-height: 1.5;
+}
+
+/* ============================================
+ TYPOGRAPHY
+ ============================================ */
+
+.title {
+ position: absolute;
+ top: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ text-transform: uppercase;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 20px;
+
+ font-size: var(--font-size-xl);
+ text-align: center;
+ text-shadow: 2px 2px 10px black;
+ z-index: 1;
+ font-family: "Cinzel Decorative", cursive;
+ color: rgba(248, 252, 2, 0.6);
+
+ margin: 0;
+ padding: var(--spacing-md);
+
+ /* Rectangle + rounded corners */
+ background-color: rgba(247, 7, 67, 0.6);
+ border: 2px solid rgba(0, 0, 0, 0.6);
+ border-radius: 15px;
+}
+
+
+/* ============================================
+ MENU
+ ============================================ */
+
+.menu {
+ position: fixed;
+ top: 0;
+ left: 50px;
+ padding: 0;
+ margin: 0;
+ z-index: var(--z-menu);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+}
+
+.menu__item {
+ background: var(--color-surface);
+ color: var(--color-text);
+ border: 1px solid var(--color-surface-light);
+ padding: var(--spacing-sm) var(--spacing-md);
+ font-size: var(--font-size-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ text-align: left;
+}
+
+.menu__item:hover {
+ background: var(--color-surface-light);
+ font-size: var(--font-size-lg);
+}
+
+.menu__item--active {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+}
+
+/* ============================================
+ GAME
+ ============================================ */
+
+.game {
+ position: fixed;
+ top: 0;
+ right: 50px;
+ padding: 0;
+ margin: 0;
+ z-index: var(--z-menu);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+}
+
+.game__item {
+ background: var(--color-surface);
+ color: var(--color-text);
+ border: 1px solid var(--color-surface-light);
+ padding: var(--spacing-sm) var(--spacing-md);
+ font-size: var(--font-size-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ text-align: right;
+}
+
+.game__item:hover {
+ background: var(--color-surface-light);
+ font-size: var(--font-size-lg);
+}
+
+.game__item--active {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+}
+
+/* ============================================
+ BUTTONS
+ ============================================ */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-sm) var(--spacing-md);
+ font-size: var(--font-size-md);
+ font-weight: 500;
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ text-decoration: none;
+}
+
+.btn:hover {
+ opacity: 0.9;
+ transform: translateY(-1px);
+}
+
+.btn:active {
+ transform: translateY(0);
+}
+
+.btn--primary {
+ background: var(--color-primary);
+ color: var(--color-text);
+}
+
+.btn--primary:hover {
+ background: var(--color-primary-hover);
+}
+
+.btn--secondary {
+ background: var(--color-surface-light);
+ color: var(--color-text);
+}
+
+.btn--success {
+ background: var(--color-success-dark);
+ color: var(--color-text);
+}
+
+.btn--danger {
+ background: var(--color-error);
+ color: var(--color-text);
+}
+
+.btn--github {
+ background: var(--color-github);
+ color: var(--color-text);
+}
+
+.btn--ghost {
+ background: transparent;
+ color: var(--color-text);
+ border: 1px solid var(--color-surface-light);
+}
+
+/* ============================================
+ INPUTS
+ ============================================ */
+.input {
+ width: 100%;
+ padding: var(--spacing-sm) var(--spacing-md);
+ font-size: var(--font-size-md);
+ background: var(--color-surface);
+ color: var(--color-text);
+ border: 1px solid var(--color-surface-light);
+ border-radius: var(--radius-md);
+ transition: border-color var(--transition-fast);
+}
+
+.input:focus {
+ outline: none;
+ border-color: var(--color-primary);
+}
+
+.input::placeholder {
+ color: var(--color-text-muted);
+}
+
+.input-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+/* ============================================
+ WINDOWS
+ ============================================ */
+.window {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: var(--color-bg);
+ border: 2px ridge var(--color-text);
+ color: var(--color-text);
+ z-index: var(--z-window);
+ display: none;
+ flex-direction: column;
+ min-width: 280px;
+ box-shadow: var(--shadow-lg);
+}
+
+.window--visible {
+ display: flex;
+}
+
+.window--left {
+ left: 25%;
+}
+
+.window--right {
+ left: 75%;
+}
+
+.window__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--spacing-sm) var(--spacing-md);
+ background: var(--color-surface);
+ cursor: move;
+ user-select: none;
+}
+
+.window__title {
+ font-weight: 500;
+ font-size: var(--font-size-md);
+}
+
+.window__close {
+ cursor: pointer;
+ font-size: var(--font-size-lg);
+ opacity: 0.8;
+ transition: opacity var(--transition-fast);
+ background: none;
+ border: none;
+ color: var(--color-text);
+ padding: 0;
+ line-height: 1;
+}
+
+.window__close:hover {
+ opacity: 1;
+}
+
+.window__body {
+ padding: var(--spacing-md);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ flex: 1;
+ overflow: auto;
+}
+
+/* ============================================
+ MESSAGES
+ ============================================ */
+.message {
+ font-size: var(--font-size-sm);
+ padding: var(--spacing-xs);
+ border-radius: var(--radius-sm);
+}
+
+.message--success {
+ color: var(--color-success);
+}
+
+.message--error {
+ color: var(--color-error);
+}
+
+.message--info {
+ color: var(--color-text-muted);
+}
+
+/* ============================================
+ LOGIN WINDOW
+ ============================================ */
+.login {
+ width: 320px;
+}
+
+.login__form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+.login__actions {
+ display: flex;
+ gap: var(--spacing-sm);
+ margin-top: var(--spacing-xs);
+}
+
+.login__divider {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ color: var(--color-text-muted);
+ font-size: var(--font-size-sm);
+ margin: var(--spacing-sm) 0;
+}
+
+.login__divider::before,
+.login__divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--color-surface-light);
+}
+
+/* ============================================
+ CHAT WINDOW
+ ============================================ */
+.chat {
+ width: 380px;
+ height: 400px;
+}
+
+.chat__output {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--spacing-sm);
+ background: var(--color-surface);
+ border-radius: var(--radius-md);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ min-height: 150px;
+}
+
+.chat__message {
+ padding: var(--spacing-xs) var(--spacing-sm);
+ background: var(--color-surface-light);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-size-sm);
+}
+
+.chat__message--own {
+ background: var(--color-primary);
+ align-self: flex-end;
+}
+
+.chat__friend-indicator {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ background-color: var(--color-success);
+ border-radius: 50%;
+ margin-right: var(--spacing-xs);
+ vertical-align: middle;
+}
+
+.chat__system {
+ color: var(--color-text-muted);
+ font-size: var(--font-size-sm);
+ font-style: italic;
+ text-align: center;
+}
+
+.chat__system--error {
+ color: var(--color-error);
+}
+
+.chat__system--success {
+ color: var(--color-success);
+}
+
+.chat__input-container {
+ display: flex;
+ gap: var(--spacing-sm);
+ margin-top: var(--spacing-sm);
+}
+
+.chat__input {
+ flex: 1;
+}
+
+.chat__controls {
+ display: flex;
+ gap: var(--spacing-sm);
+ margin-top: var(--spacing-sm);
+}
+
+/* ============================================
+ AVATAR WINDOW
+ ============================================ */
+.avatar-window {
+ width: 360px;
+}
+
+.avatar__preview {
+ width: 120px;
+ height: 120px;
+ object-fit: cover;
+ border-radius: var(--radius-full);
+ border: 3px solid var(--color-text);
+ box-shadow: var(--shadow-md);
+ background: var(--color-surface);
+ align-self: center;
+}
+
+.avatar__username {
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ text-align: center;
+ color: var(--color-text);
+ margin-top: var(--spacing-sm);
+}
+
+.avatar__controls {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ align-items: center;
+}
+
+.avatar__file-input {
+ display: none;
+}
+
+/* ============================================
+ EASTER EGG BUTTON
+ ============================================ */
+/* .easter-egg {
+ position: absolute;
+ top: 20%;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 1;
+ background: var(--color-surface);
+ color: var(--color-text);
+ border: 1px solid var(--color-surface-light);
+ padding: var(--spacing-sm) var(--spacing-md);
+ cursor: pointer;
+ font-size: var(--font-size-md);
+ border-radius: var(--radius-md);
+ transition: all var(--transition-fast);
+}
+
+.easter-egg:hover {
+ background: var(--color-error);
+ border-color: var(--color-error);
+} */
+
+/* ============================================
+ UTILITIES
+ ============================================ */
+.hidden {
+ display: none !important;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ border: 0;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.flex-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* ============================================
+ FRIENDS WINDOW
+ ============================================ */
+.friends-window {
+ width: 400px;
+ height: 450px;
+}
+
+.friends__tabs {
+ display: flex;
+ gap: var(--spacing-xs);
+ margin-bottom: var(--spacing-sm);
+}
+
+.friends__tab {
+ flex: 1;
+ padding: var(--spacing-sm);
+ background: var(--color-surface);
+ border: 1px solid var(--color-surface-light);
+ color: var(--color-text);
+ cursor: pointer;
+ font-size: var(--font-size-sm);
+ transition: all var(--transition-fast);
+}
+
+.friends__tab:hover {
+ background: var(--color-surface-light);
+}
+
+.friends__tab--active {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+}
+
+.friends__content {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: hidden;
+}
+
+.friends__search {
+ display: flex;
+ gap: var(--spacing-sm);
+ margin-bottom: var(--spacing-sm);
+}
+
+.friends__search .input {
+ flex: 1;
+}
+
+.friends__list {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+.friends__item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm);
+ background: var(--color-surface);
+ border-radius: var(--radius-md);
+}
+
+.friends__avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: var(--radius-full);
+ object-fit: cover;
+ border: 2px solid var(--color-surface-light);
+}
+
+.friends__name {
+ flex: 1;
+ font-size: var(--font-size-md);
+ font-weight: 500;
+}
+
+.friends__actions {
+ display: flex;
+ gap: var(--spacing-xs);
+}
+
+.friends__actions .btn {
+ padding: var(--spacing-xs) var(--spacing-sm);
+ font-size: var(--font-size-sm);
+}
+
+.friends__empty {
+ text-align: center;
+ color: var(--color-text-muted);
+ padding: var(--spacing-lg);
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/index.html b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/index.html
new file mode 100755
index 0000000..ee45125
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/index.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+ Transcendence.io
+
+
+
+
+
+
+ Transcendence.io
+
+
+
+
+
+
+
+
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/login.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/login.js
new file mode 100755
index 0000000..f83dfec
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/login.js
@@ -0,0 +1,239 @@
+import { Window } from './windows.js';
+import { API, STORAGE_KEYS, CSS } from './config.js';
+import { eventBus, Events } from './events.js';
+
+/**
+ * Login and registration window
+ * Emits events instead of directly importing other windows
+ */
+export class LoginWindow extends Window {
+ constructor() {
+ super({
+ name: 'login',
+ title: 'Login',
+ cssClasses: ['login']
+ });
+
+ this.buildUI();
+ this.bindEvents();
+ this.checkIfAlreadyLoggedIn();
+ }
+
+ /**
+ * Builds the user interface
+ */
+ buildUI() {
+ // Main form
+ this.form = this.createElement('div', 'login__form');
+
+ // Username field
+ this.usernameInput = this.createElement('input', CSS.INPUT, {
+ type: 'text',
+ placeholder: 'Username'
+ });
+
+ // Password field
+ this.passwordInput = this.createElement('input', CSS.INPUT, {
+ type: 'password',
+ placeholder: 'Password'
+ });
+
+ // Action buttons
+ this.actions = this.createElement('div', 'login__actions');
+
+ this.loginBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
+ text: 'Sign in'
+ });
+
+ this.registerBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
+ text: 'Register'
+ });
+
+ this.actions.append(this.loginBtn, this.registerBtn);
+
+ // Feedback message
+ this.message = this.createElement('div', CSS.MESSAGE);
+
+ // Divider
+ this.divider = this.createElement('div', 'login__divider', {
+ text: 'or'
+ });
+
+ // GitHub button
+ this.githubBtn = this.createElement('button', [CSS.BTN, CSS.BTN_GITHUB], {
+ text: 'Sign in with GitHub'
+ });
+
+ // Assembly
+ this.form.append(
+ this.usernameInput,
+ this.passwordInput,
+ this.actions,
+ this.message,
+ this.divider,
+ this.githubBtn
+ );
+
+ this.body.appendChild(this.form);
+ }
+
+ /**
+ * Attaches event handlers
+ */
+ bindEvents() {
+ this.loginBtn.addEventListener('click', () => this.handleLogin());
+ this.registerBtn.addEventListener('click', () => this.handleRegister());
+ this.githubBtn.addEventListener('click', () => this.handleGitHubLogin());
+
+ // Login with Enter
+ this.passwordInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ this.handleLogin();
+ }
+ });
+ }
+
+ /**
+ * Checks if user is already logged in
+ */
+ checkIfAlreadyLoggedIn() {
+ const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
+ if (token) {
+ this.showMessage('You are already logged in!', 'success');
+ }
+ }
+
+ /**
+ * Handles login
+ */
+ async handleLogin() {
+ const username = this.usernameInput.value.trim();
+ const password = this.passwordInput.value;
+
+ if (!username || !password) {
+ this.showMessage('Please fill in all fields', 'error');
+ return;
+ }
+
+ this.showMessage('Signing in...', 'info');
+
+ try {
+ const response = await fetch(API.AUTH.LOGIN, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password })
+ });
+
+ const data = await response.json();
+
+ if (response.ok && data.token) {
+ console.log('Login successful, storing token');
+ localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, data.token);
+ this.showMessage('Login successful! Welcome.', 'success');
+
+ console.log('Emitting USER_LOGGED_IN event');
+ // Emit login event
+ eventBus.emit(Events.USER_LOGGED_IN, { username, token: data.token });
+
+ console.log('Token stored:', !!localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN));
+
+ // Close window after delay
+ setTimeout(() => this.hide(), 1500);
+ } else {
+ const errorMsg = data?.message || 'Login failed';
+ this.showMessage(errorMsg, 'error');
+ }
+ } catch (error) {
+ console.error('Login error:', error);
+ this.showMessage('Server connection error', 'error');
+ }
+ }
+
+ /**
+ * Handles registration
+ */
+ async handleRegister() {
+ const username = this.usernameInput.value.trim();
+ const password = this.passwordInput.value;
+
+ if (!username || !password) {
+ this.showMessage('Please fill in all fields', 'error');
+ return;
+ }
+
+ this.showMessage('Registering...', 'info');
+
+ try {
+ const response = await fetch(API.AUTH.REGISTER, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password })
+ });
+
+ const data = await response.json();
+
+ if (response.ok) {
+ this.showMessage('Registration successful! You can now sign in.', 'success');
+ eventBus.emit(Events.USER_REGISTERED, { username });
+ } else {
+ const errorMsg = data?.message || 'Registration failed';
+ this.showMessage(errorMsg, 'error');
+ }
+ } catch (error) {
+ console.error('Registration error:', error);
+ this.showMessage('Server connection error', 'error');
+ }
+ }
+
+ /**
+ * Handles GitHub OAuth login
+ */
+ handleGitHubLogin() {
+ const width = 600;
+ const height = 700;
+ const left = (screen.width - width) / 2;
+ const top = (screen.height - height) / 2;
+
+ const popup = window.open(
+ API.AUTH.GITHUB,
+ 'githubOAuth',
+ `width=${width},height=${height},left=${left},top=${top}`
+ );
+
+ const handleMessage = (event) => {
+ if (event.data?.token) {
+ localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, event.data.token);
+ this.showMessage('GitHub login successful! Welcome.', 'success');
+
+ // Emit login event
+ eventBus.emit(Events.USER_LOGGED_IN, {
+ provider: 'github',
+ token: event.data.token
+ });
+
+ window.removeEventListener('message', handleMessage);
+ if (popup) popup.close();
+ }
+ };
+
+ window.addEventListener('message', handleMessage, { once: true });
+ }
+
+ /**
+ * Displays a feedback message
+ * @param {string} text - Message text
+ * @param {'success'|'error'|'info'} type - Message type
+ */
+ showMessage(text, type = 'info') {
+ this.message.textContent = text;
+ this.message.className = CSS.MESSAGE;
+
+ if (type === 'success') {
+ this.message.classList.add(CSS.MESSAGE_SUCCESS);
+ } else if (type === 'error') {
+ this.message.classList.add(CSS.MESSAGE_ERROR);
+ } else {
+ this.message.classList.add(CSS.MESSAGE_INFO);
+ }
+ }
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/pieces.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/pieces.js
new file mode 100755
index 0000000..9af9cad
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/pieces.js
@@ -0,0 +1,99 @@
+// ─────────────────────────────────────────────
+// PIÈCES
+// ─────────────────────────────────────────────
+
+class Piece {
+ constructor(startX, startY) {
+ this.position = { x: startX, y: startY };
+ this.currentRotation = 0;
+ this.rotations = this.defineRotations();
+ this.shape = this.rotations[0];
+ this.color = this.getColor();
+ }
+ defineRotations() { return [[[1]]]; }
+ getColor() { return 1; }
+ getPosition() { return { ...this.position }; }
+ getShape() { return this.shape; }
+ moveDown() { this.position.y++; }
+ moveLeft() { this.position.x--; }
+ moveRight() { this.position.x++; }
+ rotateLeft() {
+ this.currentRotation = (this.currentRotation - 1 + this.rotations.length) % this.rotations.length;
+ this.shape = this.rotations[this.currentRotation];
+ }
+ rotateRight() {
+ this.currentRotation = (this.currentRotation + 1) % this.rotations.length;
+ this.shape = this.rotations[this.currentRotation];
+ }
+}
+
+class PieceT extends Piece {
+ defineRotations() {
+ return [
+ [[0,1,0],[1,1,1],[0,0,0]],
+ [[0,1,0],[0,1,1],[0,1,0]],
+ [[0,0,0],[1,1,1],[0,1,0]],
+ [[0,1,0],[1,1,0],[0,1,0]]
+ ];
+ }
+ getColor() { return 1; }
+}
+
+class PieceL extends Piece {
+ defineRotations() {
+ return [
+ [[0,0,1],[1,1,1],[0,0,0]],
+ [[0,1,0],[0,1,0],[0,1,1]],
+ [[0,0,0],[1,1,1],[1,0,0]],
+ [[1,1,0],[0,1,0],[0,1,0]]
+ ];
+ }
+ getColor() { return 2; }
+}
+
+class PieceReverseL extends Piece {
+ defineRotations() {
+ return [
+ [[1,0,0],[1,1,1],[0,0,0]],
+ [[0,1,1],[0,1,0],[0,1,0]],
+ [[0,0,0],[1,1,1],[0,0,1]],
+ [[0,1,0],[0,1,0],[1,1,0]]
+ ];
+ }
+ getColor() { return 3; }
+}
+
+class PieceI extends Piece {
+ defineRotations() {
+ return [
+ [[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]],
+ [[0,0,1,0],[0,0,1,0],[0,0,1,0],[0,0,1,0]]
+ ];
+ }
+ getColor() { return 4; }
+}
+
+class PieceZ extends Piece {
+ defineRotations() {
+ return [
+ [[1,1,0],[0,1,1],[0,0,0]],
+ [[0,0,1],[0,1,1],[0,1,0]]
+ ];
+ }
+ getColor() { return 5; }
+}
+
+class PieceReverseZ extends Piece {
+ defineRotations() {
+ return [
+ [[0,1,1],[1,1,0],[0,0,0]],
+ [[0,1,0],[0,1,1],[0,0,1]]
+ ];
+ }
+ getColor() { return 6; }
+}
+
+class PieceO extends Piece {
+ defineRotations() { return [[[1,1],[1,1]]]; }
+ getColor() { return 7; }
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/renderer.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/renderer.js
new file mode 100755
index 0000000..f024216
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/renderer.js
@@ -0,0 +1,126 @@
+// ─────────────────────────────────────────────
+// RENDU
+// ─────────────────────────────────────────────
+
+const CELL = 30;
+const COLORS = ['#070712','#a855f7','#f97316','#3b82f6','#06b6d4','#ef4444','#22c55e','#eab308','#555577'];
+
+const ctxMain = document.getElementById('canvas-main').getContext('2d');
+const ctxNext = document.getElementById('canvas-next').getContext('2d');
+const ctxHold = document.getElementById('canvas-hold').getContext('2d');
+const ctxOpponent = document.getElementById('canvas-opponent').getContext('2d');
+
+function drawCell(ctx, x, y, colorIndex, size) {
+ const p = 1;
+ ctx.fillStyle = COLORS[colorIndex];
+ ctx.fillRect(x * size + p, y * size + p, size - p * 2, size - p * 2);
+ // Highlight
+ ctx.fillStyle = 'rgba(255,255,255,0.25)';
+ ctx.fillRect(x * size + p, y * size + p, size - p * 2, 3);
+ ctx.fillRect(x * size + p, y * size + p, 3, size - p * 2);
+ // Ombre
+ ctx.fillStyle = 'rgba(0,0,0,0.35)';
+ ctx.fillRect(x * size + p, (y + 1) * size - p - 3, size - p * 2, 3);
+ ctx.fillRect((x + 1) * size - p - 3, y * size + p, 3, size - p * 2);
+}
+
+function clearCanvas(ctx, w, h) {
+ ctx.fillStyle = '#070712';
+ ctx.fillRect(0, 0, w, h);
+}
+
+function drawGridLines(ctx, cols, rows, size) {
+ ctx.strokeStyle = 'rgba(255,255,255,0.04)';
+ ctx.lineWidth = 1;
+ for (let x = 0; x <= cols; x++) {
+ ctx.beginPath(); ctx.moveTo(x * size, 0); ctx.lineTo(x * size, rows * size); ctx.stroke();
+ }
+ for (let y = 0; y <= rows; y++) {
+ ctx.beginPath(); ctx.moveTo(0, y * size); ctx.lineTo(cols * size, y * size); ctx.stroke();
+ }
+}
+
+function drawGhost(ctx, piece, grid) {
+ if (!piece) return;
+ const ghost = { x: piece.getPosition().x, y: piece.getPosition().y };
+ const shape = piece.getShape();
+
+ while (true) {
+ ghost.y++;
+ let valid = true;
+ for (let row = 0; row < shape.length && valid; row++)
+ for (let col = 0; col < shape[row].length && valid; col++)
+ if (shape[row][col] !== 0) {
+ const ny = ghost.y + row;
+ const nx = ghost.x + col;
+ if (ny >= grid.length || grid[ny][nx] !== 0) valid = false;
+ }
+ if (!valid) { ghost.y--; break; }
+ }
+
+ if (ghost.y === piece.getPosition().y) return;
+
+ ctx.strokeStyle = 'rgba(255,255,255,0.15)';
+ ctx.lineWidth = 1;
+ for (let row = 0; row < shape.length; row++)
+ for (let col = 0; col < shape[row].length; col++)
+ if (shape[row][col] !== 0)
+ ctx.strokeRect(
+ (ghost.x + col) * CELL + 2,
+ (ghost.y + row) * CELL + 2,
+ CELL - 4, CELL - 4
+ );
+}
+
+function drawMiniPiece(ctx, piece, canvasW, canvasH) {
+ clearCanvas(ctx, canvasW, canvasH);
+ if (!piece) return;
+ const shape = piece.getShape();
+ const color = piece.getColor();
+ const s = 20;
+ const offsetX = Math.floor((canvasW / s - shape[0].length) / 2);
+ const offsetY = Math.floor((canvasH / s - shape.length) / 2);
+ for (let row = 0; row < shape.length; row++)
+ for (let col = 0; col < shape[row].length; col++)
+ if (shape[row][col] !== 0)
+ drawCell(ctx, offsetX + col, offsetY + row, color, s);
+}
+
+function render() {
+ // Grille principale
+ clearCanvas(ctxMain, 300, 600);
+ drawGridLines(ctxMain, 10, 20, CELL);
+
+ for (let y = 0; y < game.grid.length; y++)
+ for (let x = 0; x < game.grid[y].length; x++)
+ if (game.grid[y][x] !== 0)
+ drawCell(ctxMain, x, y, game.grid[y][x], CELL);
+
+ // Ghost + pièce courante
+ if (game.currentPiece) {
+ drawGhost(ctxMain, game.currentPiece, game.grid);
+ const { x, y } = game.currentPiece.getPosition();
+ const shape = game.currentPiece.getShape();
+ const color = game.currentPiece.getColor();
+ for (let row = 0; row < shape.length; row++)
+ for (let col = 0; col < shape[row].length; col++)
+ if (shape[row][col] !== 0)
+ drawCell(ctxMain, x + col, y + row, color, CELL);
+ }
+
+ // Panneaux miniatures
+ drawMiniPiece(ctxNext, game.nextPiece, 100, 80);
+ drawMiniPiece(ctxHold, game.storedPiece, 100, 80);
+
+ // Score
+ document.getElementById('score-display').textContent = game.score;
+}
+
+function renderOpponent(opponentGrid) {
+ clearCanvas(ctxOpponent, 300, 600);
+ drawGridLines(ctxOpponent, 10, 20, CELL);
+ for (let y = 0; y < opponentGrid.length; y++)
+ for (let x = 0; x < opponentGrid[y].length; x++)
+ if (opponentGrid[y][x] !== 0)
+ drawCell(ctxOpponent, x, y, opponentGrid[y][x], CELL);
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.css b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.css
new file mode 100755
index 0000000..9d29537
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.css
@@ -0,0 +1,355 @@
+:root {
+ --bg: #070712;
+ --panel: #0d0d1f;
+ --border: #1a1a3e;
+ --accent: #00ffe7;
+ --accent2:#ff00aa;
+ --dim: #3a3a6a;
+ --text: #c0c0e0;
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+body {
+ background: var(--bg);
+ font-family: 'Share Tech Mono', monospace;
+ color: var(--text);
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+}
+
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ background-image:
+ linear-gradient(rgba(0,255,231,0.03) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(0,255,231,0.03) 1px, transparent 1px);
+ background-size: 40px 40px;
+ pointer-events: none;
+ z-index: 0;
+}
+
+h1 {
+ font-family: 'Orbitron', monospace;
+ font-weight: 900;
+ font-size: 2.2rem;
+ letter-spacing: 0.4em;
+ color: var(--accent);
+ text-shadow: 0 0 20px var(--accent), 0 0 40px var(--accent);
+ margin-bottom: 20px;
+ position: relative;
+ z-index: 1;
+}
+
+/* ── Zone de jeu globale ── */
+#game-area {
+ display: flex;
+ gap: 32px;
+ align-items: flex-start;
+ position: relative;
+ z-index: 1;
+}
+
+/* ── Section locale (légèrement décalée à gauche par le flex naturel) ── */
+#local-section {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+#app {
+ display: flex;
+ gap: 16px;
+ align-items: flex-start;
+}
+
+/* ── Section adversaire ── */
+#opponent-section {
+ display: none; /* masqué jusqu'à connexion duel */
+ gap: 16px;
+ align-items: flex-start;
+}
+#opponent-section.visible {
+ display: flex;
+}
+
+.opponent-info-panel {
+ width: 130px;
+}
+
+/* ── Panneaux ── */
+.panel {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 14px;
+ width: 130px;
+ box-shadow: 0 0 20px rgba(0,255,231,0.05);
+}
+
+.panel-title {
+ font-family: 'Orbitron', monospace;
+ font-size: 0.6rem;
+ letter-spacing: 0.2em;
+ color: var(--accent);
+ text-transform: uppercase;
+ margin-bottom: 10px;
+ text-align: center;
+}
+
+canvas { display: block; border-radius: 4px; }
+
+#canvas-main {
+ border: 1px solid var(--border);
+ box-shadow: 0 0 30px rgba(0,255,231,0.08), inset 0 0 30px rgba(0,0,0,0.5);
+}
+
+#canvas-next, #canvas-hold {
+ border: 1px solid var(--border);
+ margin: 0 auto;
+}
+
+/* ── Canvas adversaire ── */
+#canvas-opponent {
+ border: 1px solid var(--accent2);
+ box-shadow: 0 0 30px rgba(255,0,170,0.08), inset 0 0 30px rgba(0,0,0,0.5);
+}
+
+/* ── Score ── */
+.score-block {
+ margin-top: 14px;
+ text-align: center;
+}
+
+.score-label {
+ font-size: 0.55rem;
+ letter-spacing: 0.2em;
+ color: var(--dim);
+ text-transform: uppercase;
+ margin-bottom: 4px;
+}
+
+.score-value {
+ font-family: 'Orbitron', monospace;
+ font-size: 1.4rem;
+ font-weight: 700;
+ color: var(--accent);
+ text-shadow: 0 0 10px var(--accent);
+}
+
+/* ── Boutons ── */
+.btn-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 14px;
+}
+
+button {
+ font-family: 'Orbitron', monospace;
+ font-size: 0.55rem;
+ letter-spacing: 0.15em;
+ font-weight: 700;
+ text-transform: uppercase;
+ padding: 10px 8px;
+ border: 1px solid;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.2s;
+ background: transparent;
+ width: 100%;
+}
+
+#btn-start { color: var(--accent); border-color: var(--accent); }
+#btn-start:hover:not(:disabled) { background: var(--accent); color: var(--bg); box-shadow: 0 0 15px var(--accent); }
+
+#btn-pause { color: var(--accent2); border-color: var(--accent2); }
+#btn-pause:hover:not(:disabled) { background: var(--accent2); color: var(--bg); box-shadow: 0 0 15px var(--accent2); }
+
+#btn-stop { color: #ef4444; border-color: #ef4444; }
+#btn-stop:hover:not(:disabled) { background: #ef4444; color: var(--bg); box-shadow: 0 0 15px #ef4444; }
+
+button:disabled { opacity: 0.3; cursor: not-allowed; }
+
+/* ── Contrôles ── */
+.controls-list {
+ margin-top: 14px;
+ font-size: 0.6rem;
+ line-height: 2;
+ color: var(--dim);
+}
+.controls-list span { color: var(--text); }
+
+/* ── Overlays ── */
+#main-wrapper,
+#opponent-wrapper { position: relative; }
+
+#overlay,
+#overlay-opponent {
+ display: none;
+ position: absolute;
+ top: 0; left: 0;
+ width: 300px;
+ height: 600px;
+ background: rgba(7,7,18,0.88);
+ border-radius: 4px;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ z-index: 10;
+ pointer-events: none;
+}
+#overlay.visible,
+#overlay-opponent.visible { display: flex; }
+
+#overlay-title {
+ font-family: 'Orbitron', monospace;
+ font-size: 1.4rem;
+ font-weight: 900;
+ letter-spacing: 0.2em;
+ color: var(--accent2);
+ text-shadow: 0 0 20px var(--accent2);
+}
+
+#overlay-score {
+ font-family: 'Orbitron', monospace;
+ font-size: 0.9rem;
+ color: var(--accent);
+}
+
+#overlay-opponent-title {
+ font-family: 'Orbitron', monospace;
+ font-size: 1.4rem;
+ font-weight: 900;
+ letter-spacing: 0.2em;
+ color: var(--accent);
+ text-shadow: 0 0 20px var(--accent);
+}
+
+#overlay-opponent-score {
+ font-family: 'Orbitron', monospace;
+ font-size: 0.9rem;
+ color: var(--accent2);
+}
+
+/* ── Panneau duel ── */
+#duel-panel {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 12px 20px;
+ margin-bottom: 14px;
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ box-shadow: 0 0 20px rgba(255,0,170,0.04);
+}
+
+.duel-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+#input-room-code {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--accent2);
+ font-family: 'Orbitron', monospace;
+ font-size: 0.7rem;
+ letter-spacing: 0.15em;
+ padding: 6px 10px;
+ width: 120px;
+ text-transform: uppercase;
+ outline: none;
+ transition: border-color 0.2s;
+}
+#input-room-code:focus {
+ border-color: var(--accent2);
+ box-shadow: 0 0 8px rgba(255,0,170,0.2);
+}
+
+#btn-join-duel { color: var(--accent2); border-color: var(--accent2); width: auto; padding: 6px 14px; }
+#btn-join-duel:hover:not(:disabled) { background: var(--accent2); color: var(--bg); box-shadow: 0 0 12px var(--accent2); }
+
+#btn-leave-duel { color: #ef4444; border-color: #ef4444; width: auto; padding: 6px 14px; }
+#btn-leave-duel:hover:not(:disabled) { background: #ef4444; color: var(--bg); box-shadow: 0 0 12px #ef4444; }
+
+#duel-status {
+ font-size: 0.6rem;
+ letter-spacing: 0.1em;
+ color: var(--dim);
+ min-width: 120px;
+}
+#duel-status.waiting { color: #f97316; }
+#duel-status.ready { color: var(--accent); }
+
+/* ── Settings Panel ── */
+#settings-panel {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 14px 20px;
+ margin-top: -250px;
+ margin-left: -600px;
+ box-shadow: 0 0 20px rgba(0,255,231,0.05);
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ width: fit-content;
+}
+
+.settings-title {
+ font-family: 'Orbitron', monospace;
+ font-size: 0.6rem;
+ letter-spacing: 0.2em;
+ color: var(--accent);
+ text-transform: uppercase;
+ text-align: center;
+ margin-bottom: 4px;
+}
+
+.settings-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ font-size: 0.6rem;
+ color: var(--dim);
+ letter-spacing: 0.05em;
+}
+
+#settings-panel input[type="number"] {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--accent);
+ font-family: 'Orbitron', monospace;
+ font-size: 0.65rem;
+ padding: 4px 8px;
+ width: 80px;
+ text-align: right;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+#settings-panel input[type="number"]:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 8px rgba(0,255,231,0.2);
+}
+
+#settings-panel input[type="number"]:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.html b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.html
new file mode 100755
index 0000000..ea0a17c
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.html
@@ -0,0 +1,121 @@
+
+
+
+
+
+ TETRIS
+
+
+
+
+
+TETRIS
+
+
+
+
Duel
+
+
+
+
+
+
—
+
+
+
+
+
+
+
+
+
+
+
Hold
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Next
+
+
+
+
← → Déplacer
+
↓ Descendre
+
Q Rot. gauche
+
W Rot. droite
+
Espace Drop
+
C Hold
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Paramètres
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.js
new file mode 100755
index 0000000..6032065
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.js
@@ -0,0 +1,366 @@
+// ─────────────────────────────────────────────
+// LOGIQUE TETRIS
+// ─────────────────────────────────────────────
+
+class Tetris {
+ constructor(onRender, onGameOver, onBlockPlaced = null, onLinesCleared = null) {
+ this.onRender = onRender;
+ this.onGameOver = onGameOver;
+ this.onBlockPlaced = onBlockPlaced;
+ this.onLinesCleared = onLinesCleared;
+
+ this.grid = this._createGrid(10, 20);
+ this.bufferGrid = this._createGrid(10, 5);
+ this.currentPiece = null;
+ this.storedPiece = null;
+ this.nextPiece = null;
+
+ this.score = 0;
+ this.initialTimeToDown = 1000;
+ this.timeToDown = 1000;
+ this.hardening = 1000;
+ this.count = 0;
+ this.decrementTTD = 100;
+
+ this.lastLandingCol = 4;
+
+ this.isRunning = false;
+ this.isPaused = false;
+ this.canStore = true;
+
+ this.animationFrameId = null;
+ this.lastTime = 0;
+ this.accumulator = 0;
+
+ this._keyHandler = this._handleKey.bind(this);
+ }
+
+ configure({ timeToDown, hardening, decrementTTD }) {
+ if (timeToDown !== undefined) this.initialTimeToDown = this.timeToDown = timeToDown;
+ if (hardening !== undefined) this.hardening = hardening;
+ if (decrementTTD !== undefined) this.decrementTTD = decrementTTD;
+ }
+
+ _createGrid(w, h) {
+ return Array.from({ length: h }, () => Array(w).fill(0));
+ }
+
+ start() {
+ if (this.isRunning) return;
+ this.isRunning = true;
+ this.isPaused = false;
+ this.grid = this._createGrid(10, 20);
+ this.score = 0;
+ this.count = 0;
+ this.timeToDown = this.initialTimeToDown;
+ this.storedPiece = null;
+ this.canStore = true;
+ this._spawnNewPiece();
+ document.addEventListener('keydown', this._keyHandler);
+ this._startGameLoop();
+ }
+
+ stop() {
+ this.isRunning = false;
+ this.isPaused = false;
+ if (this.animationFrameId !== null) {
+ cancelAnimationFrame(this.animationFrameId);
+ this.animationFrameId = null;
+ }
+ this.accumulator = 0;
+ this.lastTime = 0;
+ document.removeEventListener('keydown', this._keyHandler);
+ }
+
+ pause() {
+ if (!this.isRunning) return;
+ this.isPaused = !this.isPaused;
+ if (!this.isPaused) {
+ this.lastTime = 0;
+ this._startGameLoop();
+ }
+ }
+
+ _startGameLoop() {
+ this.lastTime = 0;
+ this.accumulator = 0;
+
+ const gameLoop = (currentTime) => {
+ if (!this.isRunning) return;
+
+ if (this.isPaused) {
+ this.animationFrameId = requestAnimationFrame(gameLoop);
+ return;
+ }
+
+ if (this.lastTime === 0) {
+ this.lastTime = currentTime;
+ this.animationFrameId = requestAnimationFrame(gameLoop);
+ return;
+ }
+
+ const deltaTime = currentTime - this.lastTime;
+ this.lastTime = currentTime;
+ this.accumulator += deltaTime;
+
+ while (this.isRunning && this.accumulator >= this.timeToDown) {
+ this._tick();
+ this.accumulator -= this.timeToDown;
+ if (this.accumulator > this.timeToDown * 3) {
+ this.accumulator = 0;
+ break;
+ }
+ }
+
+ this.onRender();
+ this.animationFrameId = requestAnimationFrame(gameLoop);
+ };
+
+ this.animationFrameId = requestAnimationFrame(gameLoop);
+ }
+
+ _tick() {
+ if (!this.currentPiece) return;
+ if (this._canMoveDown()) {
+ this.currentPiece.moveDown();
+ } else {
+ this._lockPiece();
+ this.verifierLignes();
+ this._makeHarder();
+ this._spawnNewPiece();
+ this.canStore = true;
+ if (!this._canSpawn()) this._gameOver();
+ }
+ }
+
+ _handleKey(e) {
+ if (!this.isRunning || !this.currentPiece) return;
+
+ switch (e.key) {
+ case 'ArrowLeft':
+ e.preventDefault();
+ if (!this.isPaused && this._canMoveLeft()) this.currentPiece.moveLeft();
+ break;
+ case 'ArrowRight':
+ e.preventDefault();
+ if (!this.isPaused && this._canMoveRight()) this.currentPiece.moveRight();
+ break;
+ case 'ArrowDown':
+ e.preventDefault();
+ if (!this.isPaused && this._canMoveDown()) {
+ this.currentPiece.moveDown();
+ this.score += 1;
+ this.accumulator = 0;
+ }
+ break;
+ case ' ':
+ e.preventDefault();
+ if (!this.isPaused) this._hardDrop();
+ break;
+ case 'q': case 'Q':
+ e.preventDefault();
+ if (!this.isPaused) this._rotatePiece(-1);
+ break;
+ case 'w': case 'W':
+ e.preventDefault();
+ if (!this.isPaused) this._rotatePiece(1);
+ break;
+ case 'c': case 'C':
+ e.preventDefault();
+ if (!this.isPaused) this._storePiece();
+ break;
+ }
+
+ this.onRender();
+ }
+
+ _hardDrop() {
+ if (!this.currentPiece) return;
+ let dist = 0;
+ while (this._canMoveDown()) { this.currentPiece.moveDown(); dist++; }
+ this.score += dist * 2;
+ this._lockPiece();
+ this.verifierLignes();
+ this._makeHarder();
+ this._spawnNewPiece();
+ this.canStore = true;
+ this.accumulator = 0;
+ if (!this._canSpawn()) this._gameOver();
+ }
+
+ _rotatePiece(direction) {
+ if (!this.currentPiece) return;
+ const originalPos = { ...this.currentPiece.getPosition() };
+
+ if (direction === -1) this.currentPiece.rotateLeft();
+ else this.currentPiece.rotateRight();
+
+ if (!this._isValidPosition()) {
+ this.currentPiece.moveRight();
+ if (this._isValidPosition()) return;
+
+ this.currentPiece.moveLeft();
+ this.currentPiece.moveLeft();
+ if (this._isValidPosition()) return;
+
+ this.currentPiece.moveLeft();
+ if (this._isValidPosition()) return;
+
+ this.currentPiece.moveRight();
+ this.currentPiece.moveRight();
+ this.currentPiece.position.y--;
+ if (this._isValidPosition()) return;
+
+ this.currentPiece.position.y = originalPos.y;
+ this.currentPiece.position.x = originalPos.x;
+ if (direction === -1) this.currentPiece.rotateRight();
+ else this.currentPiece.rotateLeft();
+ }
+ }
+
+ _storePiece() {
+ if (!this.canStore || !this.currentPiece) return;
+
+ if (this.storedPiece === null) {
+ this.storedPiece = this.currentPiece;
+ this._spawnNewPiece();
+ } else {
+ const temp = this.storedPiece;
+ this.storedPiece = this.currentPiece;
+ this.currentPiece = temp;
+ this.currentPiece.position.x = 3;
+ this.currentPiece.position.y = 0;
+ }
+ this.canStore = false;
+ this.accumulator = 0;
+ }
+
+ _spawnNewPiece() {
+ this.currentPiece = this.nextPiece || this._createRandomPiece();
+ this.nextPiece = this._createRandomPiece();
+ this._updateBufferGrid();
+ }
+
+ _createRandomPiece() {
+ const types = [PieceT, PieceL, PieceReverseL, PieceI, PieceZ, PieceReverseZ, PieceO];
+ return new types[Math.floor(Math.random() * types.length)](3, 0);
+ }
+
+ _updateBufferGrid() {
+ this.bufferGrid = this._createGrid(10, 5);
+ if (!this.nextPiece) return;
+ const shape = this.nextPiece.getShape();
+ const offsetX = Math.floor((10 - shape[0].length) / 2);
+ for (let y = 0; y < shape.length; y++)
+ for (let x = 0; x < shape[y].length; x++)
+ if (shape[y][x] !== 0)
+ this.bufferGrid[y + 1][x + offsetX] = this.nextPiece.getColor();
+ }
+
+ verifierLignes() {
+ let cleared = 0;
+ for (let y = this.grid.length - 1; y >= 0; y--) {
+ if (this.grid[y].every(c => c !== 0)) {
+ this.grid.splice(y, 1);
+ this.grid.unshift(Array(10).fill(0));
+ cleared++;
+ y++;
+ }
+ }
+ const points = [0, 100, 300, 500, 800];
+ this.score += points[cleared];
+ this.count += points[cleared];
+ if (this.onLinesCleared && cleared > 0)
+ this.onLinesCleared(cleared, this.lastLandingCol);
+ }
+
+ _makeHarder() {
+ if (this.count >= this.hardening) {
+ this.count = 0;
+ this.timeToDown = Math.max(100, this.timeToDown - this.decrementTTD);
+ }
+ }
+
+ _canMoveDown() {
+ if (!this.currentPiece) return false;
+ const { x, y } = this.currentPiece.getPosition();
+ const shape = this.currentPiece.getShape();
+ for (let row = 0; row < shape.length; row++)
+ for (let col = 0; col < shape[row].length; col++)
+ if (shape[row][col] !== 0) {
+ const ny = y + row + 1;
+ const nx = x + col;
+ if (ny >= this.grid.length || this.grid[ny][nx] !== 0) return false;
+ }
+ return true;
+ }
+
+ _canMoveLeft() {
+ if (!this.currentPiece) return false;
+ const { x, y } = this.currentPiece.getPosition();
+ const shape = this.currentPiece.getShape();
+ for (let row = 0; row < shape.length; row++)
+ for (let col = 0; col < shape[row].length; col++)
+ if (shape[row][col] !== 0) {
+ const nx = x + col - 1;
+ if (nx < 0 || this.grid[y + row][nx] !== 0) return false;
+ }
+ return true;
+ }
+
+ _canMoveRight() {
+ if (!this.currentPiece) return false;
+ const { x, y } = this.currentPiece.getPosition();
+ const shape = this.currentPiece.getShape();
+ for (let row = 0; row < shape.length; row++)
+ for (let col = 0; col < shape[row].length; col++)
+ if (shape[row][col] !== 0) {
+ const nx = x + col + 1;
+ if (nx >= this.grid[0].length || this.grid[y + row][nx] !== 0) return false;
+ }
+ return true;
+ }
+
+ _isValidPosition() {
+ if (!this.currentPiece) return false;
+ const { x, y } = this.currentPiece.getPosition();
+ const shape = this.currentPiece.getShape();
+ for (let row = 0; row < shape.length; row++)
+ for (let col = 0; col < shape[row].length; col++)
+ if (shape[row][col] !== 0) {
+ const gx = x + col;
+ const gy = y + row;
+ if (gx < 0 || gx >= this.grid[0].length ||
+ gy < 0 || gy >= this.grid.length ||
+ this.grid[gy][gx] !== 0) return false;
+ }
+ return true;
+ }
+
+ _canSpawn() { return this._isValidPosition(); }
+
+ _lockPiece() {
+ if (!this.currentPiece) return;
+ const { x, y } = this.currentPiece.getPosition();
+ const shape = this.currentPiece.getShape();
+ const color = this.currentPiece.getColor();
+ for (let row = 0; row < shape.length; row++)
+ for (let col = 0; col < shape[row].length; col++)
+ if (shape[row][col] !== 0)
+ this.grid[y + row][x + col] = color;
+ this.lastLandingCol = x + Math.floor(shape[0].length / 2);
+ if (this.onBlockPlaced) this.onBlockPlaced(this.grid.map(r => [...r]));
+ }
+
+ addGarbageLines(lines) {
+ if (!this.isRunning || !lines.length) return;
+ this.grid.splice(0, lines.length);
+ for (const line of lines) this.grid.push([...line]);
+ if (!this._isValidPosition()) this._gameOver();
+ }
+
+ _gameOver() {
+ this.stop();
+ this.onGameOver(this.score);
+ }
+}
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/ui.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/ui.js
new file mode 100755
index 0000000..d738e54
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/ui.js
@@ -0,0 +1,162 @@
+// ─────────────────────────────────────────────
+// UI
+// ─────────────────────────────────────────────
+
+const btnStart = document.getElementById('btn-start');
+const btnPause = document.getElementById('btn-pause');
+const btnStop = document.getElementById('btn-stop');
+const overlay = document.getElementById('overlay');
+const inputTTD = document.getElementById('input-ttd');
+const inputHardening = document.getElementById('input-hardening');
+const inputDecrement = document.getElementById('input-decrement');
+
+// Duel UI
+const btnJoinDuel = document.getElementById('btn-join-duel');
+const btnLeaveDuel = document.getElementById('btn-leave-duel');
+const inputRoomCode = document.getElementById('input-room-code');
+const duelStatusEl = document.getElementById('duel-status');
+const opponentSection = document.getElementById('opponent-section');
+
+function updateButtons() {
+ btnStart.disabled = game.isRunning;
+ btnPause.disabled = !game.isRunning;
+ btnStop.disabled = !game.isRunning;
+ btnPause.textContent = game.isPaused ? 'Resume' : 'Pause';
+ inputTTD.disabled = game.isRunning;
+ inputHardening.disabled = game.isRunning;
+ inputDecrement.disabled = game.isRunning;
+}
+
+function showOverlay(title, score) {
+ document.getElementById('overlay-title').textContent = title;
+ document.getElementById('overlay-score').textContent = score !== undefined ? `Score : ${score}` : '';
+ overlay.classList.add('visible');
+}
+
+function hideOverlay() {
+ overlay.classList.remove('visible');
+}
+
+// ─────────────────────────────────────────────
+// SOCKET + DUEL
+// ─────────────────────────────────────────────
+
+const socket = io({
+ auth: { token: localStorage.getItem('auth_token') },
+ reconnection: true,
+ reconnectionAttempts: 5,
+ reconnectionDelay: 1000,
+ transports: ['websocket', 'polling']
+});
+
+let duel = null;
+
+function updateDuelStatus(status, opponentName) {
+ duelStatusEl.className = '';
+ if (status === 'waiting') {
+ duelStatusEl.textContent = 'En attente d\'un adversaire…';
+ duelStatusEl.classList.add('waiting');
+ opponentSection.classList.remove('visible');
+ } else if (status === 'ready') {
+ duelStatusEl.textContent = `Prêt — ${opponentName}`;
+ duelStatusEl.classList.add('ready');
+ opponentSection.classList.add('visible');
+ if (duel) duel.hideOpponentOverlay();
+ renderOpponent(duel ? duel.opponentGrid : Array.from({length:20}, () => Array(10).fill(0)));
+ } else {
+ duelStatusEl.textContent = '—';
+ opponentSection.classList.remove('visible');
+ }
+}
+
+function startLocalGame() {
+ hideOverlay();
+ game.start();
+ updateButtons();
+ render();
+}
+
+btnJoinDuel.addEventListener('click', () => {
+ const code = inputRoomCode.value.trim().toUpperCase();
+ if (!code) return;
+ if (duel) { duel.leave(); }
+ duel = new Duel(socket, game, updateDuelStatus, startLocalGame);
+ duel.join(code);
+ btnJoinDuel.disabled = true;
+ btnLeaveDuel.disabled = false;
+ inputRoomCode.disabled = true;
+ updateDuelStatus('waiting', null);
+});
+
+btnLeaveDuel.addEventListener('click', () => {
+ if (duel) { duel.leave(); duel = null; }
+ btnJoinDuel.disabled = false;
+ btnLeaveDuel.disabled = true;
+ inputRoomCode.disabled = false;
+ updateDuelStatus(null, null);
+});
+
+// ─────────────────────────────────────────────
+// INIT
+// ─────────────────────────────────────────────
+
+const game = new Tetris(
+ // onRender
+ () => {
+ if (duel) duel.synchronize_game();
+ render();
+ updateButtons();
+ },
+ // onGameOver
+ (score) => {
+ if (duel) duel.onLocalGameOver(score);
+ render();
+ updateButtons();
+ showOverlay('GAME OVER', score);
+ },
+ // onBlockPlaced — relay duel
+ (grid) => {
+ if (duel) duel.onLocalBlockPlaced(grid, game.score);
+ },
+ // onLinesCleared — relay duel
+ (count, holeCol) => {
+ if (duel) duel.onLocalLinesCleared(count, holeCol);
+ }
+);
+
+btnStart.addEventListener('click', () => {
+ if (duel && duel.isReady) {
+ duel.startDuel(); // déclenche les deux parties via le serveur
+ } else {
+ startLocalGame(); // solo
+ }
+});
+
+btnPause.addEventListener('click', () => {
+ game.pause();
+ updateButtons();
+ if (game.isPaused) showOverlay('PAUSE');
+ else hideOverlay();
+});
+
+btnStop.addEventListener('click', () => {
+ game.stop();
+ updateButtons();
+ render();
+ showOverlay('STOPPED');
+});
+
+function applySettings() {
+ game.configure({
+ timeToDown: parseInt(inputTTD.value, 10),
+ hardening: parseInt(inputHardening.value, 10),
+ decrementTTD: parseInt(inputDecrement.value, 10),
+ });
+}
+
+inputTTD.addEventListener('change', applySettings);
+inputHardening.addEventListener('change', applySettings);
+inputDecrement.addEventListener('change', applySettings);
+
+render();
+updateButtons();
diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/windows.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/windows.js
new file mode 100755
index 0000000..229a229
--- /dev/null
+++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/windows.js
@@ -0,0 +1,234 @@
+import { CSS } from './config.js';
+import { eventBus, Events } from './events.js';
+
+/**
+ * Centralized window registry
+ * Manages window visibility and positioning
+ */
+class WindowRegistry {
+ constructor() {
+ this.windows = new Map();
+ }
+
+ /**
+ * Registers a window in the registry
+ * @param {string} name - Unique window name
+ * @param {Window} window - Window instance
+ */
+ register(name, window) {
+ this.windows.set(name, window);
+ }
+
+ /**
+ * Gets a window by its name
+ * @param {string} name - Window name
+ * @returns {Window|undefined}
+ */
+ get(name) {
+ return this.windows.get(name);
+ }
+
+ /**
+ * Returns all visible windows
+ * @returns {Window[]}
+ */
+ getVisible() {
+ return Array.from(this.windows.values()).filter(w => w.isVisible());
+ }
+
+ /**
+ * Reorganizes visible windows
+ */
+ reorganize() {
+ const visible = this.getVisible();
+
+ if (visible.length === 0) return;
+
+ if (visible.length === 1) {
+ visible[0].setPosition('center');
+ } else if (visible.length === 2) {
+ visible[0].setPosition('left');
+ visible[1].setPosition('right');
+ } else {
+ visible.forEach(w => w.setPosition('center'));
+ }
+ }
+
+ /**
+ * Shows a window and reorganizes
+ * @param {string} name - Window name
+ */
+ show(name) {
+ const window = this.get(name);
+ if (window) {
+ window.show();
+ }
+ }
+
+ /**
+ * Hides a window and reorganizes
+ * @param {string} name - Window name
+ */
+ hide(name) {
+ const window = this.get(name);
+ if (window) {
+ window.hide();
+ }
+ }
+
+ /**
+ * Toggles window visibility
+ * @param {string} name - Window name
+ */
+ toggle(name) {
+ const window = this.get(name);
+ if (window) {
+ window.toggle();
+ }
+ }
+}
+
+// Singleton registry instance
+export const windowRegistry = new WindowRegistry();
+
+/**
+ * Base class for all windows
+ * Uses CSS classes instead of inline styles
+ */
+export class Window {
+ /**
+ * @param {Object} options - Configuration options
+ * @param {string} options.name - Unique name for the registry
+ * @param {string} options.title - Title displayed in the header
+ * @param {string[]} [options.cssClasses] - Additional CSS classes
+ */
+ constructor({ name, title, cssClasses = [] }) {
+ this.name = name;
+
+ // Create main container
+ this.element = document.createElement('div');
+ this.element.className = [CSS.WINDOW, ...cssClasses].join(' ');
+
+ // Header
+ this.header = document.createElement('div');
+ this.header.className = CSS.WINDOW_HEADER;
+
+ this.titleElement = document.createElement('span');
+ this.titleElement.className = CSS.WINDOW_TITLE;
+ this.titleElement.textContent = title;
+
+ this.closeBtn = document.createElement('button');
+ this.closeBtn.className = CSS.WINDOW_CLOSE;
+ this.closeBtn.textContent = '✖';
+ this.closeBtn.setAttribute('aria-label', 'Close');
+ this.closeBtn.addEventListener('click', () => this.hide());
+
+ this.header.append(this.titleElement, this.closeBtn);
+
+ // Body
+ this.body = document.createElement('div');
+ this.body.className = CSS.WINDOW_BODY;
+
+ // Assembly
+ this.element.append(this.header, this.body);
+ document.body.appendChild(this.element);
+
+ // Register in the registry
+ windowRegistry.register(name, this);
+ }
+
+ /**
+ * Checks if the window is visible
+ * @returns {boolean}
+ */
+ isVisible() {
+ return this.element.classList.contains(CSS.WINDOW_VISIBLE);
+ }
+
+ /**
+ * Sets the window position
+ * @param {'center'|'left'|'right'} position
+ */
+ setPosition(position) {
+ this.element.classList.remove('window--left', 'window--right');
+
+ if (position === 'left') {
+ this.element.classList.add('window--left');
+ } else if (position === 'right') {
+ this.element.classList.add('window--right');
+ }
+ }
+
+ /**
+ * Shows the window
+ */
+ show() {
+ const wasHidden = !this.isVisible();
+ this.element.classList.add(CSS.WINDOW_VISIBLE);
+
+ if (wasHidden) {
+ windowRegistry.reorganize();
+ eventBus.emit(Events.WINDOW_OPENED, { name: this.name });
+ }
+ }
+
+ /**
+ * Hides the window
+ */
+ hide() {
+ const wasVisible = this.isVisible();
+ this.element.classList.remove(CSS.WINDOW_VISIBLE);
+
+ if (wasVisible) {
+ windowRegistry.reorganize();
+ eventBus.emit(Events.WINDOW_CLOSED, { name: this.name });
+ }
+ }
+
+ /**
+ * Toggles visibility
+ */
+ toggle() {
+ if (this.isVisible()) {
+ this.hide();
+ } else {
+ this.show();
+ }
+ }
+
+ /**
+ * Updates the window title
+ * @param {string} title
+ */
+ setTitle(title) {
+ this.titleElement.textContent = title;
+ }
+
+ /**
+ * Creates an element with CSS classes
+ * @param {string} tag - HTML tag
+ * @param {string|string[]} classes - CSS classes
+ * @param {Object} [attrs] - Additional attributes
+ * @returns {HTMLElement}
+ */
+ createElement(tag, classes, attrs = {}) {
+ const element = document.createElement(tag);
+ const classList = Array.isArray(classes) ? classes : [classes];
+ element.className = classList.filter(Boolean).join(' ');
+
+ Object.entries(attrs).forEach(([key, value]) => {
+ if (key === 'text') {
+ element.textContent = value;
+ } else if (key === 'html') {
+ element.innerHTML = value;
+ } else {
+ element.setAttribute(key, value);
+ }
+ });
+
+ return element;
+ }
+}
+
+// Export old class name for compatibility (alias)
+export { Window as fenetre };
diff --git a/Transcendence/srcs/backend/routes/game_room.js b/Transcendence/srcs/backend/routes/game_room.js
index 0d19d24..d3f722b 100644
--- a/Transcendence/srcs/backend/routes/game_room.js
+++ b/Transcendence/srcs/backend/routes/game_room.js
@@ -18,6 +18,22 @@ router.get('/', authenticateToken, async(req, res) =>
}
});
+// Get list of rooms currently being played (for spectators)
+router.get('/playing', authenticateToken, async(req, res) =>
+{
+ try
+ {
+ const rooms = await gameRoomService.listPlayingRooms();
+ res.json(rooms);
+ }
+ catch (err)
+ {
+ console.error(err);
+ res.status(500).json({error: 'Server error'});
+ }
+});
+
+
// IMPORTANT: This route must be before /:roomId to avoid "current" being interpreted as a roomId
router.get('/current', authenticateToken, async(req, res) =>
{
@@ -134,4 +150,39 @@ router.post('/:roomId/leave', authenticateToken, async(req, res) =>
}
});
-export default router;
\ No newline at end of file
+
+// Join a room as spectator
+router.post('/:roomId/spectate', authenticateToken, async(req, res) =>
+{
+ try
+ {
+ const room = await gameRoomService.spectateRoom(req.params.roomId, req.user.userId);
+ res.json(room);
+ }
+ catch(err)
+ {
+ console.error(err);
+ if (err.message.includes('not found') || err.message.includes('not in playing') || err.message.includes('already in'))
+ res.status(400).json({error: err.message});
+ else
+ res.status(500).json({error: err.message});
+ }
+});
+
+// Leave spectator mode
+router.post('/:roomId/leave-spectate', authenticateToken, async(req, res) =>
+{
+ try
+ {
+ await gameRoomService.leaveSpectateRoom(req.params.roomId, req.user.userId);
+ res.json({message: 'Left spectator mode successfully'});
+ }
+ catch(err)
+ {
+ console.error(err);
+ res.status(500).json({error: 'Server error'});
+ }
+});
+
+
+export default router;
diff --git a/Transcendence/srcs/backend/services/game_room.js b/Transcendence/srcs/backend/services/game_room.js
index e2fd1f6..f59e7e8 100644
--- a/Transcendence/srcs/backend/services/game_room.js
+++ b/Transcendence/srcs/backend/services/game_room.js
@@ -44,6 +44,71 @@ async function listActiveRooms()
return (result.rows);
}
+async function listPlayingRooms()
+{
+ const result = await query
+ (
+ `SELECT r.*, COUNT(p.id) as player_count
+ FROM game_rooms r
+ LEFT JOIN game_players p ON r.id = p.room_id
+ WHERE r.status = 'playing'
+ GROUP BY r.id
+ ORDER BY player_count DESC, r.created_at DESC`
+ );
+ return (result.rows);
+}
+
+async function spectateRoom(roomId, userId)
+{
+ const room = await getRoomById(roomId);
+ if (!room)
+ throw new Error('Room not found');
+
+ if (room.status !== 'playing')
+ throw new Error('Room is not in playing status');
+
+ // Check if user is already a player in any active game
+ const playerInGame = await query
+ (
+ `SELECT r.id, r.name, r.status
+ FROM game_rooms r
+ JOIN game_players gp ON r.id = gp.room_id
+ WHERE gp.user_id = $1 AND r.status IN ('waiting', 'playing')
+ LIMIT 1`,
+ [userId]
+ );
+
+ if (playerInGame.rows.length > 0)
+ {
+ const gameRoom = playerInGame.rows[0];
+ if (gameRoom.id === parseInt(roomId))
+ throw new Error('You cannot spectate a game you are playing in');
+ else
+ throw new Error('You are already in an active game');
+ }
+
+ return (room);
+}
+
+async function leaveSpectateRoom(roomId, userId)
+{
+ const playerCount = await query
+ (
+ 'SELECT COUNT(*) FROM game_players WHERE room_id = $1',
+ [roomId]
+ );
+
+ if (parseInt(playerCount.rows[0].count) === 0)
+ {
+ await query
+ (
+ 'DELETE FROM game_rooms WHERE id = $1',
+ [roomId]
+ );
+ }
+}
+
+
async function joinRoom(roomId, userId)
{
const room = await getRoomById(roomId);
@@ -123,13 +188,68 @@ async function getCurrentRoom(userId)
return (result.rows[0] || null);
}
+// Update room status (waiting, playing, ended)
+async function updateRoomStatus(roomId, status)
+{
+ const validStatuses = ['waiting', 'playing', 'ended'];
+ if (!validStatuses.includes(status))
+ throw new Error('Invalid status');
+
+ let updateQuery = 'UPDATE game_rooms SET status = $1';
+ const params = [status, roomId];
+
+ if (status === 'playing')
+ {
+ updateQuery += ', started_at = NOW()';
+ }
+ else if (status === 'ended')
+ {
+ updateQuery += ', ended_at = NOW()';
+ }
+
+ updateQuery += ' WHERE id = $2 RETURNING *';
+
+ const result = await query(updateQuery, params);
+ return (result.rows[0]);
+}
+
+async function resetRoomScores(roomId)
+{
+ await query
+ (
+ 'UPDATE game_players SET score = 0 WHERE room_id = $1',
+ [roomId]
+ );
+}
+
+async function cleanupEndedRooms()
+{
+ await query
+ (
+ 'DELETE FROM game_players WHERE room_id IN (SELECT id FROM game_rooms WHERE status = $1)',
+ ['ended']
+ );
+
+ await query
+ (
+ 'DELETE FROM game_rooms WHERE status = $1',
+ ['ended']
+ );
+}
+
export default
{
createRoom,
getRoomById,
listActiveRooms,
+ listPlayingRooms,
+ spectateRoom,
+ leaveSpectateRoom,
joinRoom,
leaveRoom,
getRoomPlayers,
- getCurrentRoom
-};
\ No newline at end of file
+ getCurrentRoom,
+ updateRoomStatus,
+ resetRoomScores,
+ cleanupEndedRooms
+};
diff --git a/Transcendence/srcs/backend/services/socket.js b/Transcendence/srcs/backend/services/socket.js
index 019e424..aede582 100644
--- a/Transcendence/srcs/backend/services/socket.js
+++ b/Transcendence/srcs/backend/services/socket.js
@@ -27,6 +27,42 @@ async function broadcastRoomsList(io) {
}
}
+// Check if a playing game has only 1 player left and auto-stop it
+async function checkAndStopSinglePlayerGame(io, roomId, dbRoomId) {
+ if (!dbRoomId) return;
+
+ try {
+ // Check if room is in 'playing' status
+ const room = await gameRoomService.getRoomById(dbRoomId);
+ if (!room || room.status !== 'playing') return;
+
+ // Count remaining players
+ const players = await gameRoomService.getRoomPlayers(dbRoomId);
+ if (players.length <= 1) {
+ console.log(`Room ${dbRoomId} has only ${players.length} player(s) left, ending game`);
+
+ // Update room status to 'ended'
+ await gameRoomService.updateRoomStatus(dbRoomId, 'waiting');
+ await gameRoomService.resetRoomScores(dbRoomId);
+
+ // Remove from game state
+ gameRooms.delete(roomId);
+
+ // Notify remaining player(s)
+ io.to(roomId).emit('game-ended');
+ io.to(roomId).emit('game-message', {
+ message: 'La partie s\'est terminée car il ne reste qu\'un seul joueur',
+ type: 'info'
+ });
+
+ // Broadcast updated rooms list
+ broadcastRoomsList(io);
+ }
+ } catch (err) {
+ console.error('Error checking single player game:', err);
+ }
+}
+
// Save round points to database (only the difference from round start)
async function saveRoundPoints(currentScores, roundStartScores) {
for (const [username, currentPoints] of Object.entries(currentScores)) {
@@ -185,16 +221,94 @@ function setupSocketIO(io)
socket.gameRoomId = null;
socket.gameRoomDbId = null;
+ // Check if game should auto-stop due to single player
+ await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
// Broadcast updated rooms list
broadcastRoomsList(io);
}
});
+ // Join a game room as spectator
+ socket.on('game-spectate-room', async (data) => {
+ console.log('Received game-spectate-room from', socket.user.username, 'data:', data);
+ const roomId = `game-room-${data.roomId}`;
+
+ // Verify room exists and is in playing status, and user is not already in a game
+ try {
+ const room = await gameRoomService.spectateRoom(data.roomId, socket.user.userId);
+
+ socket.join(roomId);
+ socket.gameRoomId = roomId;
+ socket.gameRoomDbId = data.roomId;
+ socket.isSpectator = true;
+ console.log(`${socket.user.username} joined ${roomId} as spectator`);
+
+ // Send confirmation
+ socket.emit('game-spectate-joined', {
+ roomId: data.roomId,
+ success: true
+ });
+
+ // Notify others that a spectator joined
+ socket.to(roomId).emit('game-spectator-joined', {
+ username: socket.user.username
+ });
+
+ // Send current game state
+ const gameState = gameRooms.get(roomId);
+ if (gameState && gameState.isPlaying) {
+ socket.emit('game-state-sync', {
+ isPlaying: gameState.isPlaying,
+ drawer: gameState.drawer,
+ wordLength: gameState.currentWord ? gameState.currentWord.length : 0,
+ revealedLetters: gameState.revealedLetters,
+ revealedWord: gameState.revealedWord || [],
+ guessedLetters: gameState.guessedLetters,
+ players: gameState.players,
+ scores: gameState.scores || {}
+ });
+ }
+ } catch (err) {
+ console.error('Error joining as spectator:', err);
+ socket.emit('game-spectate-error', {
+ error: err.message || 'Cannot spectate this room'
+ });
+ }
+ });
+
+ // Leave spectator mode
+ socket.on('game-leave-spectate', () => {
+ if (socket.gameRoomId && socket.isSpectator) {
+ const roomId = socket.gameRoomId;
+
+ socket.to(roomId).emit('game-spectator-left', {
+ username: socket.user.username
+ });
+
+ socket.leave(roomId);
+ console.log(`${socket.user.username} left spectator mode in ${roomId}`);
+
+ socket.gameRoomId = null;
+ socket.gameRoomDbId = null;
+ socket.isSpectator = false;
+ }
+ });
+
+
// Start the game
- socket.on('game-start', (data) => {
+ socket.on('game-start', async (data) => {
console.log('Received game-start event from', socket.user.username);
console.log('socket.gameRoomId:', socket.gameRoomId);
+ // Security check: need at least 2 players
+ if (!data.players || data.players.length < 2) {
+ console.log('Game start rejected: not enough players');
+ socket.emit('game-start-error', {
+ error: 'Il faut au moins 2 joueurs pour commencer'
+ });
+ return;
+ }
+
const gameStartedData = {
drawer: data.drawer,
players: data.players
@@ -209,6 +323,33 @@ function setupSocketIO(io)
return;
}
+ // Verify player count from database
+ const dbRoomId = socket.gameRoomDbId;
+ if (dbRoomId) {
+ try {
+ const players = await gameRoomService.getRoomPlayers(dbRoomId);
+ if (players.length < 2) {
+ console.log(`Game start rejected: only ${players.length} player(s) in room`);
+ socket.emit('game-start-error', {
+ error: 'Il faut au moins 2 joueurs pour commencer'
+ });
+ return;
+ }
+ } catch (err) {
+ console.error('Error checking player count:', err);
+ }
+ }
+
+ // Update room status to 'playing' in database
+ if (dbRoomId) {
+ try {
+ await gameRoomService.updateRoomStatus(dbRoomId, 'playing');
+ console.log(`Room ${dbRoomId} status updated to 'playing'`);
+ } catch (err) {
+ console.error('Error updating room status to playing:', err);
+ }
+ }
+
// Initialize scores for all players
const scores = {};
data.players.forEach(p => scores[p] = 0);
@@ -233,6 +374,9 @@ function setupSocketIO(io)
socket.emit('game-started', gameStartedData);
console.log(`Game started in ${roomId} by ${socket.user.username}`);
+
+ // Broadcast updated rooms list (this room should no longer appear)
+ broadcastRoomsList(io);
});
// Drawer sets the word
@@ -269,6 +413,12 @@ function setupSocketIO(io)
const roomId = socket.gameRoomId;
if (!roomId) return;
+ // Spectators cannot draw
+ if (socket.isSpectator) {
+ console.log(`Spectator ${socket.user.username} tried to draw - blocked`);
+ return;
+ }
+
// Broadcast drawing to all other players in the room
socket.to(roomId).emit('game-draw', {
x1: data.x1,
@@ -285,6 +435,9 @@ function setupSocketIO(io)
const roomId = socket.gameRoomId;
if (!roomId) return;
+ // Spectators cannot clear canvas
+ if (socket.isSpectator) return;
+
socket.to(roomId).emit('game-clear-canvas');
});
@@ -293,6 +446,13 @@ function setupSocketIO(io)
const roomId = socket.gameRoomId;
if (!roomId) return;
+ // Spectators cannot make guesses
+ if (socket.isSpectator) {
+ console.log(`Spectator ${socket.user.username} tried to guess - blocked`);
+ return;
+ }
+
+
const gameState = gameRooms.get(roomId);
if (!gameState || !gameState.currentWord) return;
@@ -416,13 +576,71 @@ function setupSocketIO(io)
});
});
+ socket.on('leave-room-during-game', async () => {
+ const roomId = socket.gameRoomId;
+ const dbRoomId = socket.gameRoomDbId;
+ const userId = socket.user.userId;
+ const username = socket.user.username;
+
+ if (!roomId || !dbRoomId || !userId) return;
+
+ console.log(`Player ${username} leaving room ${roomId} during game`);
+
+ try
+ {
+ socket.leave(roomId);
+
+ await gameRoomService.leaveRoom(dbRoomId, userId);
+
+ io.to(roomId).emit('game-player-left', {
+ username: username,
+ message: `${username} a quitté la partie`
+ });
+
+ const gameState = gameRooms.get(roomId);
+ if (gameState)
+ {
+ gameState.players = gameState.players.filter(p => p !== username);
+ delete gameState.scores[username];
+
+ io.to(roomId).emit('scores-updated', gameState.scores);
+ }
+
+ await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
+
+ socket.gameRoomId = null;
+ socket.gameRoomDbId = null;
+
+ broadcastRoomsList(io);
+ }
+ catch (err)
+ {
+ console.error('Error leaving room during game:', err);
+ }
+ });
+
// End game
- socket.on('game-end', () => {
+ socket.on('game-end', async () => {
const roomId = socket.gameRoomId;
if (!roomId) return;
+ // Update room status to 'waiting' in database
+ const dbRoomId = socket.gameRoomDbId;
+ if (dbRoomId) {
+ try {
+ await gameRoomService.updateRoomStatus(dbRoomId, 'waiting');
+ await gameRoomService.resetRoomScores(dbRoomId);
+ console.log(`Room ${dbRoomId} status updated to 'waiting'`);
+ } catch (err) {
+ console.error('Error updating room status to waiting:', err);
+ }
+ }
+
gameRooms.delete(roomId);
io.to(roomId).emit('game-ended');
+
+ // Broadcast updated rooms list
+ broadcastRoomsList(io);
});
// ============================================
@@ -540,11 +758,39 @@ function setupSocketIO(io)
console.log(`User disconnected: ${socket.user.username}`);
- // Notify game room if player was in one
+ // Notify game room if player/spectator was in one
if (socket.gameRoomId) {
const roomId = socket.gameRoomId;
const dbRoomId = socket.gameRoomDbId;
+ // If spectator, just notify and leave
+ if (socket.isSpectator) {
+ socket.to(roomId).emit('game-spectator-left', {
+ username: socket.user.username
+ });
+ console.log(`Spectator ${socket.user.username} disconnected from ${roomId}`);
+ } else {
+ // Regular player disconnect
+ socket.to(roomId).emit('game-player-left', {
+ username: socket.user.username,
+ userId: socket.user.userId
+ });
+
+ // Get updated player list and broadcast
+ if (dbRoomId) {
+ try {
+ const players = await gameRoomService.getRoomPlayers(dbRoomId);
+ io.to(roomId).emit('game-players-updated', { players });
+ } catch (err) {
+ console.log('Room may have been deleted on disconnect:', err.message);
+ }
+ }
+
+ // Check if game should auto-stop due to single player
+ await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
+
+
+
socket.to(roomId).emit('game-player-left', {
username: socket.user.username,
userId: socket.user.userId
@@ -596,4 +842,4 @@ function _tetrisRelayToOpponent(socket, event, data) {
}
export { broadcastRoomsList };
-export default setupSocketIO;
\ No newline at end of file
+export default setupSocketIO;
diff --git a/Transcendence/srcs/frontend/src/game_room.js b/Transcendence/srcs/frontend/src/game_room.js
index 4ae0040..c19993d 100644
--- a/Transcendence/srcs/frontend/src/game_room.js
+++ b/Transcendence/srcs/frontend/src/game_room.js
@@ -14,9 +14,17 @@ export class GameRoomWindow extends Window {
this.currentRoom = null;
this.roomsList = [];
this.socket = null;
+ this.isSpectating = false;
+ this.messageTimeout = null;
this.buildUI();
this.bindEvents();
+ // Handle page close/refresh to disconnect socket
+ window.addEventListener('beforeunload', () => {
+ if (this.socket?.connected) {
+ this.socket.disconnect();
+ }
+ });
eventBus.on(Events.USER_LOGGED_IN, () => {
this.updateTabsAccess();
this.checkCurrentRoom();