This commit is contained in:
sionow
2026-01-27 15:04:04 +01:00
parent 9e4c84f01b
commit ee73bcc35a
37 changed files with 9 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
srcs/.DS_Store
*.DS_Store
+14
View File
@@ -0,0 +1,14 @@
all : up
up :
@docker compose -f ./docker-compose.yml up -d
clean :
@docker compose -f ./docker-compose.yml down -t 1
fclean :
@docker compose -f ./docker-compose.yml down -v -t 1
@docker system prune -af --volumes
re : fclean up
+54
View File
@@ -0,0 +1,54 @@
# Transcendence
Exemple d'../.env fonctionnel:
POSTGRES_PASSWORD=coucou
JWT_SECRET=superlongsecretkeyatleast32characterspleasenevercommitthis
POSTGRES_DB=database
POSTGRES_HOST=database
POSTGRES_USER=user
GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxxxxxxxxxxxxx
GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GITHUB_CALLBACK_URL=http://localhost:8080/api/auth/github/callback
Les Variables d'env GITHUB_* sont a generer sur ce lien 'https://github.com/settings/applications/new'
Gestion de friendship dans POSTGRESQL:
'pending' → demande envoyée
'accepted' → amis
'blocked' → bloqué
'rejected' → refusé
Ressource:
https://www.postgresql.org/docs/
https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
https://docs.github.com/fr/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app
/////////////////////////////////////////////////////////////////////////////////////////
BACKEND
17/01 - Ajout du service/route pour le systeme de game_room
permet aux joueurs de creer et rejoindre des rooms
une room vide est automatiquement detruite.
- Presence d'une fonction affichant toutes les rooms joignables
ainsi qu'une autre fonction pour afficher tous les joueurs de la room avec
leur scores et leur etat actuel.
- Aucun moyen de changer l'etat de la room de waiting a en cours ou finished
ca attendra le systeme du jeu
21/01 - Ajout du service/route pour le systeme d'avatar
permet aux utilisateurs de changer ou supprimer leur avatar actuel
- Ajout egalement d'une simple fonction pour recuperer l'avatar d'un utilisateur (pour le frontend)
DATABASE
17/01 Ajout des tables game_rooms, game_players, game_rounds, words
- nom, status et parametres de la game
- joueurs dans la game, leur scores et leur role actuel (dessinateur, devineur)
- historique de la game, qui a dessine quoi precedemment ainsi que les timers des rounds, sera aussi utile si on veut faire les stats de compte a l'avenir.
- contient la liste des mots utilisable par les joueurs
21/01 Ajout de avatar_url dans la table users
+48
View File
@@ -0,0 +1,48 @@
volumes:
data:
networks:
transcendence:
driver: bridge
services:
database:
container_name: database
image: postgres:latest
ports:
- "5432:5432"
volumes:
- data:/var/lib/postgresql/data/pg15/
env_file:
- ../.env
networks:
- transcendence
restart: always
backend:
container_name: backend
build: ./srcs/backend
expose:
- "3001"
# ports:
# - "3001:3001"
depends_on:
- database
volumes:
- ./srcs/backend/avatar:/app/avatar
env_file:
- ../.env
networks:
- transcendence
restart: always
frontend:
container_name: frontend
build: ./srcs/frontend/
ports:
- "8080:80"
depends_on:
- backend
networks:
- transcendence
restart: always
+7
View File
@@ -0,0 +1,7 @@
#include <stdio.h>
int main()
{
printf("Program received signal SIGSEGV, Segmentation Fault.\n__GI_raise (sig=sig@entry=6) at 0x54ffg67a ../sysdeps/unix/sysv/linux/c_balo.ken:666\nSee #845515 --api-fuck-you to get more information about it");
return 1;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+153
View File
@@ -0,0 +1,153 @@
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 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',
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,
query,
ensureOauthClient
};
+15
View File
@@ -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"]
+56
View File
@@ -0,0 +1,56 @@
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 {waitForDb, createTables, 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();
// 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.get('/api', (req, res) => res.send('Backend running'));
server.listen(3001, () =>
{
console.log('Server ready and listening');
});
}
startServer();
@@ -0,0 +1,23 @@
import jwt from 'jsonwebtoken';
// Check if the webtoken is valid
export default function authMiddleware(req, res, next)
{
const header = req.headers.authorization;
if (!header)
return (res.status(401).json({error: 'Missing token'}));
const token = header.split(' ')[1];
try
{
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload;
next();
}
catch
{
res.status(401).json({error: 'Invalid token'});
}
};
+18
View File
@@ -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"
}
}
+88
View File
@@ -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(`<!doctype html><html><body><script>window.opener && window.opener.postMessage({token: '${token}'}, '*'); window.close();</script></body></html>`);
} catch (err) {
console.error(err);
res.status(500).send('GitHub OAuth error');
}
});
export default router;
@@ -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;
@@ -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;
@@ -0,0 +1,98 @@
import express from 'express';
import gameRoomService from '../services/game_room.js';
import authenticateToken from '../middleware/auth.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'});
}
});
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);
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);
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);
res.json({message: 'Left room successfully'});
}
catch(err)
{
console.error(err);
res.status(500).json({error: 'Server error'});
}
});
export default router;
@@ -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;
@@ -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};
@@ -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
};
@@ -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
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_<receiver_id>
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
};
@@ -0,0 +1,119 @@
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 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
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);
}
export default
{
createRoom,
getRoomById,
listActiveRooms,
joinRoom,
leaveRoom,
getRoomPlayers
};
@@ -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};
@@ -0,0 +1,73 @@
import jwt from 'jsonwebtoken';
import chatService from './global_chat.js';
import friendsService from './friends.js';
function setupSocketIO(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'});
}
});
socket.on('disconnect', () =>
{
console.log(`User disconnected: ${socket.user.username}`);
});
});
}
export default setupSocketIO;
+5
View File
@@ -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;"]
+38
View File
@@ -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";
}
}
+86
View File
@@ -0,0 +1,86 @@
/**
* 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';
/**
* Main application class
* Handles initialization and menu interactions
*/
class App {
constructor() {
this.initWindows();
this.initMenu();
this.initEasterEgg();
}
/**
* Initializes all windows
*/
initWindows() {
// Windows automatically register themselves in the registry
new LoginWindow();
new GlobalChat();
new AvatarWindow();
new FriendsWindow();
}
/**
* Initializes the main menu
* Uses event delegation instead of IDs
*/
initMenu() {
const menu = document.querySelector('.menu');
if (!menu) {
console.warn('Menu not found');
return;
}
// Action to window name mapping
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;
}
});
}
/**
* Initializes the easter egg button
*/
initEasterEgg() {
const easterEgg = document.querySelector('.easter-egg');
if (easterEgg) {
easterEgg.addEventListener('click', () => {
alert('You clicked when we told you not to!');
});
}
}
}
// Start the application when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => new App());
} else {
new App();
}
+221
View File
@@ -0,0 +1,221 @@
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();
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);
// 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.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);
}
}
/**
* 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);
}
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* 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'
}
};
// 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'
};
// Colors (for reference, mainly used in CSS)
export const COLORS = {
PRIMARY: '#0066cc',
SUCCESS: '#3cff01',
ERROR: '#ff4d4d',
GITHUB: '#24292e',
BACKGROUND: '#000',
SURFACE: '#222',
TEXT: '#fff'
};
+114
View File
@@ -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;
}
+88
View File
@@ -0,0 +1,88 @@
/**
* 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) {
if (this.listeners.has(event)) {
this.listeners.get(event).forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`Error in listener for "${event}":`, error);
}
});
}
}
}
// 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'
};
+441
View File
@@ -0,0 +1,441 @@
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 name = this.createElement('span', CSS.FRIENDS_NAME, {
text: user.username
});
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, name, 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);
}
}
}
@@ -0,0 +1,276 @@
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.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 ? '<span class="chat__friend-indicator"></span>' : '';
msg.innerHTML = `${friendIndicator}<strong>${this.escapeHtml(username)}:</strong> ${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();
}
/**
* 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;
}
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.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 });
});
// Handle initial data (recent messages + friend IDs)
this.socket.on('chat-init', (data) => {
console.log('Received chat init data:', data.messages.length, 'messages');
this.friendIds = new Set(data.friendIds || []);
// Display recent messages
data.messages.forEach(msg => {
const isFriend = this.friendIds.has(msg.sender_id);
this.addChatMessage(msg.username, msg.content, false, isFriend);
});
});
this.socket.on('chat-message', (msg) => {
const isFriend = this.friendIds.has(msg.sender_id);
this.addChatMessage(msg.username, msg.content, false, isFriend);
eventBus.emit(Events.CHAT_MESSAGE_RECEIVED, msg);
});
}
}
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>scribl.lidl_edition</title>
<link rel="stylesheet" href="style.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700&display=swap" rel="stylesheet" />
</head>
<body>
<h1 class="title">scribl.lidl_edition</h1>
<nav class="menu" aria-label="Menu principal">
<button class="menu__item" data-action="login" aria-label="Login">Login</button>
<button class="menu__item" data-action="chat" aria-label="Global chat">Global chat</button>
<button class="menu__item" data-action="avatar" aria-label="Avatar">Avatar</button>
<button class="menu__item" data-action="friends" aria-label="Amis">Amis</button>
</nav>
<button class="easter-egg" data-action="easter-egg">Ne cliquez pas !</button>
<script type="module" src="app.js"></script>
</body>
</html>
+235
View File
@@ -0,0 +1,235 @@
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) {
localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, data.token);
this.showMessage('Login successful! Welcome.', 'success');
// Emit login event
eventBus.emit(Events.USER_LOGGED_IN, { username, token: data.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);
}
}
}
+611
View File
@@ -0,0 +1,611 @@
/* ============================================
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: #000;
--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: 4rem;
--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 {
font-size: var(--font-size-base);
background-color: var(--color-bg);
}
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: 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);
}
/* ============================================
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);
}
+234
View File
@@ -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 };