4 Commits

Author SHA1 Message Date
Georges 6bc71a1746 Initialize README with project details and instructions
Added project description, instructions, features, dependencies, team information, project management details, technical stack, and features list to README.
2026-03-31 17:09:08 +02:00
H3XploR 13f93fb332 https installed 2026-03-24 14:29:04 +01:00
gprunet 801750da96 Notifs + Logout + delete Avatar 2026-03-22 18:26:50 +01:00
H3XploR 82623b0078 Merge pull request #21 from OlaketalAmigo/modular_code
cleaned
2026-03-22 13:48:56 +01:00
18 changed files with 391 additions and 61 deletions
+138
View File
@@ -0,0 +1,138 @@
*This project has been created as part of the 42 curriculum by agallon, gprunet, yantoine and tfauve-p*
***DESCRIPTION***
For starters, ft_transcendence is a wonderful project based on building a web-application running from Docker containers where the goal is, for the first time to do whatever we want, yet still we need to follow multiples criteria based on a number of points to grind to set the project as finished.
For such a project our group thought about the "CATETRIBBL.IO". We chose to make a web application featuring multiples games such as Tetris one of the very first game ever developed and Skkribl.io the amazing drawing game !
But beware ! A mysterious noble cat named Wiskas The Third is gone ... It is said that he's been lurking around trapping 42's students into infinite conversation known as "tunnel". If you see him please report to us as soon as possible !
***INSTRUCTIONS***
Like every 42 project you will need to git clone it into a valid repository, then add our functional .env file at the root of the repository. After all that, make use of the "make" command and watch our fabulous containers building themselves ! Look for https://localhost:8443/ once it's built, remember that you need to login in order to play on our web app !
Outside of 42 environment you will obviously need Docker and Make installed.
***RESOURCES***
https://www.geeksforgeeks.org
https://developer.mozilla.org/fr/docs/Web/JavaScript
https://www.w3schools.com/js/
https://www.tigerdata.com/learn/postgres-cheat-sheet
https://www.programiz.com/css/button-styling
https://developer.mozilla.org/fr/docs/Web/CSS
https://chatgpt.com/
https://www.gimp.org/tutorials/
AI was mostly used to ask questions and deepen understanding, it was also used to generate multiple samples of what we could do front-end wise.
**FEATURES:**
- Login
- Avatar
- Global Chat
- Skribbl.io + Spectator mode
- Tetris + Duels
- Wiskas the Third
Use of the framework Express for the back-end because its compatible with jsonwebtoken(JWT) and contains solid and well tested features.
**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"
***TEAM INFORMATION***
Tfauve-p : The project manager, is in lead of organizing all the meeting with the team which changed over time, including then some recruitment. There was some adjustments to make over our vision of the project while coding it on GitHub.
Yantoine : The project owner, is in lead of both games, Tetris and Skkribl.io, made core decisions on features about these and got the work completed. His communication skills were very important due to the front-end / back-end relationship needed in order to achieve this glorious project.
Gprunet : The technical lead, is in charge of the back-end, made some strong decisions on the architectures of the project in terms of technology used. Created the entire database and most of the foundation of this project such as the builder files.
Agallon : Developer in the front-end action, he joined the team after the project was done but managed to innovate and brought to life the marvelous Wiskas the Third. Furthermore he also cared about the integrity of the web application and greatly improved the user experience through logical decisions.
***Project Management***
The task's sharing was based on our own advance of the 42 cursus, meaning that Gprunet and Yantoine started coding the app sooner than Tfauve-p and Agallon.
Gprunet: Back-end + spectator mode
Yantoine: Github auth, games and Front/Back sockets
Tfauve-p: Front-end Designer
Agallon: Adjustements on Front-end and some new features.
***TECHNICAL STACK***
Front-end: JavaScript, HTML, CSS, NGINX
Back-end: JavaScript, Express, JWT, multer, etc...
Database: PostgreSQL because it uses a permissibe open-source licence and is feature-rich and powerful for the scale of our project
Since python doesn't have many front-end framework we opted to use JavaScript for both front and back.
After learning about JWT and learning that Express had a great synergy with it the choice was natural.
***DATABASES SCHEMA***
***FEATURES LIST***
***MODULES**
Total : 24pts ( 14pts for 100% 19pts for 125% )
**WEB**
Major : Use a back end and front end framework
Major : Implement real-time features
Major : Allow users to interact with others
Major : A public API to interact with the database
Minor : A complete notification system for all creation, update and deletion account
**ACCESSIBILITY**
Minor : Support for additional browsers
**USER MANAGEMENT**
Major : Standard user management and authentication
Minor : Game statistics and match history ???
Minor : Implement remote authentication
**GAMING AND USER EXPERIENCE**
Major : Implement a complete web-based game where users can play against each other
Major : Remote players, Enable two players on separate computers to play the same game
Major : Multiplayer game
Major : Add another game with user history and matchmaking
Minor : Advanced chat features ????
Minor : Game customization options
Minor : Spectator mode for games
**INDIVIDUAL CONTRIBUTION**
+2 -1
View File
@@ -38,7 +38,8 @@ services:
container_name: frontend
build: ./srcs/frontend/
ports:
- "8080:80"
- "8080:8080"
- "8443:8443"
depends_on:
- backend
networks:
+11
View File
@@ -26,6 +26,17 @@ router.post('/login', async(req, res) =>
res.status(result.status).json(result.data);
});
router.post('/logout', async(req, res) =>
{
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token)
return (res.status(401).json({error: 'Missing token'}));
const result = await authService.logout(token);
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}&` +
+1 -1
View File
@@ -25,7 +25,7 @@ router.post('/upload', authenticateToken, upload.single('avatar'), async(req, re
res.status(result.status).json(result.data);
});
router.delete('/', authenticateToken, async(req, res) =>
router.delete('/delete', authenticateToken, async(req, res) =>
{
const result = await avatarService.deleteAvatar(req.user.userId);
res.status(result.status).json(result.data);
+25 -1
View File
@@ -2,6 +2,30 @@ import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import {query} from '../db.js';
async function logout(token)
{
try
{
if (!token)
return ({status: 400, data: {error: 'Missing token'}});
try
{
jwt.verify(token, process.env.JWT_SECRET);
}
catch
{
return ({status: 401, data: {error: 'Invalid token'}});
}
return ({status: 200, data: {message: 'Logged out'}});
}
catch (err)
{
console.error(err);
return ({status: 500, data: {error: 'Server error'}});
}
}
async function login(username, password)
{
try
@@ -60,4 +84,4 @@ async function register(username, password)
}
};
export default {register, login};
export default {register, login, logout};
@@ -69,6 +69,9 @@ async function deleteAvatar(userId) {
if (currentAvatar === null)
return ({status: 404, data: {error: 'User not found'}});
if (currentAvatar === DEFAULT_AVATAR)
return ({status: 400, data: {error: 'Cannot delete default avatar'}});
// Reset the avatar to the default one
await setAvatar(DEFAULT_AVATAR, userId);
+8 -1
View File
@@ -1,5 +1,12 @@
FROM nginx:alpine
RUN apk add --no-cache openssl && \
mkdir -p /etc/nginx/ssl && \
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/nginx/ssl/key.pem \
-out /etc/nginx/ssl/cert.pem \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
COPY src /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
EXPOSE 8080 8443
CMD ["nginx", "-g", "daemon off;"]
+13 -2
View File
@@ -1,5 +1,13 @@
server {
listen 80;
listen 8080;
return 301 https://$host:8443$request_uri;
}
server {
listen 8443 ssl;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
root /usr/share/nginx/html;
index index.html;
@@ -14,6 +22,7 @@ server {
proxy_pass http://backend:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
}
# Socket.IO WebSocket proxying
@@ -25,7 +34,9 @@ server {
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;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /avatar/ {
+4 -1
View File
@@ -4,6 +4,7 @@
*/
import { windowRegistry } from './core/windows.js';
import { LoginWindow } from './windows/login.js';
import { LogoutWindow } from './windows/logout.js';
import { GlobalChat } from './windows/global_chat.js';
import { AvatarWindow } from './windows/avatar.js';
import { FriendsWindow } from './windows/friends.js';
@@ -32,6 +33,7 @@ class App {
new FriendsWindow();
new GameRoomWindow();
new StatsWindow();
new LogoutWindow();
}
/**
@@ -49,7 +51,8 @@ class App {
'login': 'login',
'chat': 'chat',
'avatar': 'avatar',
'friends': 'friends'
'friends': 'friends',
'logout': 'logout'
};
// Event delegation on the menu
@@ -6,12 +6,14 @@
export const API = {
AUTH: {
LOGIN: '/api/auth/login',
LOGOUT: '/api/auth/logout',
REGISTER: '/api/auth/register',
GITHUB: '/api/auth/github'
},
AVATAR: {
GET: '/api/avatar/me',
UPLOAD: '/api/avatar/upload'
UPLOAD: '/api/avatar/upload',
DELETE: '/api/avatar/delete'
},
FRIENDS: {
LIST: '/api/friends',
@@ -82,6 +82,7 @@ export const Events = {
// Avatar
AVATAR_UPDATED: 'avatar:updated',
AVATAR_DELETED: 'avatar:deleted',
// Chat
CHAT_CONNECTED: 'chat:connected',
@@ -228,6 +228,56 @@ export class Window {
return element;
}
NotficationContainer()
{
if (document.getElementById('notification-container')) return;
const container = this.createElement('div');
container.id = 'notification-container';
Object.assign(container.style, {
position: 'fixed',
top: '20px',
right: '20px',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
gap: '10px'
});
document.body.appendChild(container);
}
showNotification(message, color) {
this.NotficationContainer();
const container = document.getElementById('notification-container');
if (!container) return;
const notification = document.createElement('div');
notification.textContent = message;
Object.assign(notification.style, {
backgroundColor: color,
color: 'white',
padding: '10px 20px',
borderRadius: '5px',
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
opacity: '0',
transform: 'translateY(-8px)',
transition: 'opacity 0.5s ease, transform 0.5s ease'
});
container.appendChild(notification);
requestAnimationFrame(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
});
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(-8px)';
setTimeout(() => notification.remove(), 500);
}, 2200);
}
}
// Export old class name for compatibility (alias)
@@ -17,6 +17,7 @@
<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>
<button class="menu__item" data-action="logout" aria-label="Logout">Logout</button>
</nav>
<nav class="game" aria-label="Game">
@@ -67,8 +67,12 @@ export class AvatarWindow extends Window {
this.refreshBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
text: 'Refresh'
});
this.deleteBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
text: 'Delete avatar'
});
this.controls.append(this.statsBtn, this.chooseBtn, this.saveBtn, this.refreshBtn);
this.controls.append(this.statsBtn, this.chooseBtn, this.saveBtn, this.refreshBtn, this.deleteBtn);
// Feedback message
this.message = this.createElement('div', CSS.MESSAGE);
@@ -93,6 +97,7 @@ export class AvatarWindow extends Window {
this.chooseBtn.addEventListener('click', () => this.fileInput.click());
this.saveBtn.addEventListener('click', () => this.uploadAvatar());
this.refreshBtn.addEventListener('click', () => this.loadAvatar());
this.deleteBtn.addEventListener('click', () => this.deleteAvatar());
}
/**
@@ -212,12 +217,14 @@ export class AvatarWindow extends Window {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) {
this.showMessage('You must be logged in', 'error');
this.showNotification('You must be logged in to change your avatar', 'red');
return;
}
const file = this.fileInput.files?.[0];
if (!file) {
this.showMessage('Select an image first', 'error');
this.showNotification('Please select an image to upload', 'red');
return;
}
@@ -240,6 +247,7 @@ export class AvatarWindow extends Window {
if (!response.ok) {
const errorMsg = data?.error || data?.message || 'Upload failed';
this.showMessage(errorMsg, 'error');
this.showNotification('Failed to upload avatar.', 'red');
return;
}
@@ -248,11 +256,47 @@ export class AvatarWindow extends Window {
}
this.showMessage('Avatar saved!', 'success');
this.showNotification('Avatar updated successfully!', 'green');
eventBus.emit(Events.AVATAR_UPDATED, { url: data?.avatar_url });
} catch (error) {
console.error('Avatar upload error:', error);
this.showMessage('Upload error', 'error');
this.showNotification('Failed to upload avatar.', 'red');
}
}
async deleteAvatar() {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) {
this.showMessage('You must be logged in', 'error');
this.showNotification('You must be logged in to delete your avatar', 'red');
return;
}
try {
const response = await fetch(API.AVATAR.DELETE, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
this.showMessage('Failed to delete avatar', 'error');
this.showNotification('Failed to delete avatar.', 'red');
return;
}
this.preview.src = '';
this.showMessage('Avatar deleted!', 'success');
this.showNotification('Avatar deleted successfully!', 'green');
eventBus.emit(Events.AVATAR_DELETED);
} catch (error) {
console.error('Avatar delete error:', error);
this.showMessage('Delete error', 'error');
this.showNotification('Failed to delete avatar.', 'red');
}
}
@@ -718,7 +718,7 @@ export class GameRoomWindow extends Window {
const altPort = window.GLOBAL_CHAT_ALT_PORT;
if (altPort) {
const host = location.hostname || 'localhost';
this.socket = io(`http://${host}:${altPort}`, ioConfig);
this.socket = io(`${location.protocol}//${host}:${altPort}`, ioConfig);
} else {
this.socket = io(ioConfig);
}
@@ -840,17 +840,20 @@ export class GameRoomWindow extends Window {
const name = this.roomNameInput.value.trim();
if (!name) {
this.showMessage('Entrez un nom pour le salon', 'error');
this.showNotification('Entrez un nom pour le salon', 'red');
return;
}
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) {
this.showMessage('Connectez-vous pour creer un salon', 'info');
this.showNotification('Connectez-vous pour créer un salon', 'red');
return;
}
if (this.currentRoom) {
this.showMessage('Vous etes deja dans un salon. Quittez-le d\'abord.', 'error');
this.showNotification('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'red');
return;
}
@@ -864,6 +867,7 @@ export class GameRoomWindow extends Window {
this.currentRoom = currentData;
this.enterLobby(currentData);
this.showMessage('Vous etes deja dans un salon', 'error');
this.showNotification('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'red');
return;
}
}
@@ -884,6 +888,7 @@ export class GameRoomWindow extends Window {
if (this.roomNameExists(name)) {
this.showMessage('Un salon avec ce nom existe deja', 'error');
this.showNotification('Un salon avec ce nom existe deja', 'red');
return;
}
@@ -905,6 +910,7 @@ export class GameRoomWindow extends Window {
this.showMessage('Salon cree', 'success');
eventBus.emit(Events.ROOM_CREATED, data);
this.enterLobby(data);
this.showNotification(`Vous avez créé le salon "${data.name}"`, 'green');
} catch (error) {
console.error('Create room error:', error);
this.showMessage('Erreur de connexion', 'error');
@@ -1037,6 +1043,7 @@ export class GameRoomWindow extends Window {
if (this.currentRoom) {
this.showMessage('Vous etes deja dans un salon. Quittez-le d\'abord.', 'error');
this.showNotification('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'red');
return;
}
@@ -1050,6 +1057,7 @@ export class GameRoomWindow extends Window {
this.currentRoom = currentData;
this.enterLobby(currentData);
this.showMessage('Vous etes deja dans un salon', 'error');
this.showNotification('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'red');
return;
}
}
@@ -222,7 +222,7 @@ export class GlobalChat extends Window {
const altPort = window.GLOBAL_CHAT_ALT_PORT;
if (altPort) {
const host = location.hostname || 'localhost';
this.socket = io(`http://${host}:${altPort}`, ioConfig);
this.socket = io(`${location.protocol}//${host}:${altPort}`, ioConfig);
} else {
this.socket = io(ioConfig);
}
@@ -17,7 +17,6 @@ export class LoginWindow extends Window {
this.buildUI();
this.bindEvents();
this.checkIfAlreadyLoggedIn();
this.NotficationContainer();
}
/**
@@ -221,55 +220,6 @@ export class LoginWindow extends Window {
window.addEventListener('message', handleMessage, { once: true });
}
NotficationContainer()
{
if (document.getElementById('notification-container')) return;
const container = this.createElement('div');
container.id = 'notification-container';
Object.assign(container.style, {
position: 'fixed',
top: '20px',
right: '20px',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
gap: '10px'
});
document.body.appendChild(container);
}
showNotification(message, color) {
const container = document.getElementById('notification-container');
if (!container) return;
const notification = document.createElement('div');
notification.textContent = message;
Object.assign(notification.style, {
backgroundColor: color,
color: 'white',
padding: '10px 20px',
borderRadius: '5px',
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
opacity: '0',
transform: 'translateY(-8px)',
transition: 'opacity 0.5s ease, transform 0.5s ease'
});
container.appendChild(notification);
requestAnimationFrame(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
});
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(-8px)';
setTimeout(() => notification.remove(), 500);
}, 2200);
}
/**
* Displays a feedback message
* @param {string} text - Message text
@@ -0,0 +1,76 @@
import { Window } from '../core/windows.js';
import { API, STORAGE_KEYS, CSS } from '../core/config.js';
import { eventBus, Events } from '../core/events.js';
export class LogoutWindow extends Window {
constructor() {
super({
name: 'logout',
title: 'Logout',
cssClasses: ['logout-window']
});
this.buildUI();
this.bindEvents();
}
buildUI() {
this.text = this.createElement('div', 'logout__text', {
text: 'Are you sure you want to log out?'
});
this.actions = this.createElement('div', 'logout__actions');
this.yesBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
text: 'Yes'
});
this.noBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
text: 'No'
});
this.actions.append(this.yesBtn, this.noBtn);
this.body.append(this.text, this.actions);
}
bindEvents() {
this.yesBtn.addEventListener('click', () => this.confirmLogout());
this.noBtn.addEventListener('click', () => this.hide());
}
show () {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) {
this.text.textContent = 'You need to login first';
this.yesBtn.style.display = 'none';
this.noBtn.textContent = 'OK';
} else {
this.text.textContent = 'Are you sure you want to log out?';
this.yesBtn.style.display = 'inline-flex';
this.noBtn.textContent = 'No';
}
super.show();
}
async confirmLogout() {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (token)
{
try
{
await fetch(API.AUTH.LOGOUT, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
}
catch (err)
{
console.warn('Logout failed:', err);
this.showNotification('Logout failed. Please try again.', 'red');
return;
}
}
localStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN);
eventBus.emit(Events.USER_LOGGED_OUT);
setTimeout(() => window.location.reload(), 500);
this.showNotification('You have been logged out successfully.', 'green');
}
}