From e9c22defc3bda2397cd030a6b8a0f61f5a9e70d1 Mon Sep 17 00:00:00 2001 From: bitsearch Date: Tue, 27 Jan 2026 02:01:19 +0100 Subject: [PATCH] code plus clean et gestion des amis --- srcs/backend/index.js | 2 + srcs/backend/routes/friends.js | 69 ++++ srcs/backend/services/friends.js | 216 +++++++++++ srcs/frontend/src/app.js | 131 ++++--- srcs/frontend/src/avatar.js | 266 +++++++------ srcs/frontend/src/config.js | 109 ++++++ srcs/frontend/src/element.js | 146 ++++++-- srcs/frontend/src/events.js | 88 +++++ srcs/frontend/src/friends.js | 441 ++++++++++++++++++++++ srcs/frontend/src/global_chat.js | 370 +++++++++--------- srcs/frontend/src/index.html | 28 +- srcs/frontend/src/login.js | 365 ++++++++++-------- srcs/frontend/src/style.css | 625 +++++++++++++++++++++++++++++-- srcs/frontend/src/windows.js | 292 +++++++++++---- 14 files changed, 2510 insertions(+), 638 deletions(-) create mode 100644 srcs/backend/routes/friends.js create mode 100644 srcs/backend/services/friends.js create mode 100644 srcs/frontend/src/config.js create mode 100644 srcs/frontend/src/events.js create mode 100644 srcs/frontend/src/friends.js diff --git a/srcs/backend/index.js b/srcs/backend/index.js index c7a9be8..f9e7961 100644 --- a/srcs/backend/index.js +++ b/srcs/backend/index.js @@ -6,6 +6,7 @@ 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'; @@ -43,6 +44,7 @@ async function startServer() 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, () => diff --git a/srcs/backend/routes/friends.js b/srcs/backend/routes/friends.js new file mode 100644 index 0000000..da7c677 --- /dev/null +++ b/srcs/backend/routes/friends.js @@ -0,0 +1,69 @@ +import express from 'express'; +import friendsService from '../services/friends.js'; +import authenticateToken from '../middleware/auth.js'; + +const router = express.Router(); + +// Get friends list +router.get('/', authenticateToken, async (req, res) => { + const result = await friendsService.getFriends(req.user.userId); + res.status(result.status).json(result.data); +}); + +// Get pending friend requests +router.get('/requests', authenticateToken, async (req, res) => { + const result = await friendsService.getPendingRequests(req.user.userId); + res.status(result.status).json(result.data); +}); + +// Search users +router.get('/search', authenticateToken, async (req, res) => { + const { q } = req.query; + if (!q || q.trim().length === 0) { + return res.status(400).json({ error: 'Search query required' }); + } + const result = await friendsService.searchUsers(req.user.userId, q.trim()); + res.status(result.status).json(result.data); +}); + +// Send friend request +router.post('/request/:userId', authenticateToken, async (req, res) => { + const toUserId = parseInt(req.params.userId); + if (isNaN(toUserId)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + const result = await friendsService.sendFriendRequest(req.user.userId, toUserId); + res.status(result.status).json(result.data); +}); + +// Accept friend request +router.post('/accept/:userId', authenticateToken, async (req, res) => { + const fromUserId = parseInt(req.params.userId); + if (isNaN(fromUserId)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + const result = await friendsService.acceptFriendRequest(req.user.userId, fromUserId); + res.status(result.status).json(result.data); +}); + +// Decline friend request +router.post('/decline/:userId', authenticateToken, async (req, res) => { + const fromUserId = parseInt(req.params.userId); + if (isNaN(fromUserId)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + const result = await friendsService.declineFriendRequest(req.user.userId, fromUserId); + res.status(result.status).json(result.data); +}); + +// Remove friend +router.delete('/:userId', authenticateToken, async (req, res) => { + const friendId = parseInt(req.params.userId); + if (isNaN(friendId)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + const result = await friendsService.removeFriend(req.user.userId, friendId); + res.status(result.status).json(result.data); +}); + +export default router; diff --git a/srcs/backend/services/friends.js b/srcs/backend/services/friends.js new file mode 100644 index 0000000..ad2f66b --- /dev/null +++ b/srcs/backend/services/friends.js @@ -0,0 +1,216 @@ +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_ + 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' } }; + } +} + +/** + * 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, + getPendingRequests, + searchUsers, + sendFriendRequest, + acceptFriendRequest, + declineFriendRequest, + removeFriend +}; diff --git a/srcs/frontend/src/app.js b/srcs/frontend/src/app.js index 19d705a..ec84433 100644 --- a/srcs/frontend/src/app.js +++ b/srcs/frontend/src/app.js @@ -1,51 +1,86 @@ -import {Element, MenuElement} from "./element.js"; -import {LoginWindow} from "./login.js"; -import { GlobalChat } from "./global_chat.js"; -import { AvatarWindow } from "./avatar.js"; +/** + * 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'; -function direBonjour() { - alert("clicked !"); +/** + * 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!'); + }); + } + } } -// Define the elements of the menu (logical structure) -const menuElement = new Element("menu"); -const loginElement = new MenuElement("login"); -const registeredElement = new MenuElement("registered"); -const explorerElement = new MenuElement("explorer"); -const accueilElement = new MenuElement("accueil"); -const globalChatElement = new MenuElement("global_chat"); -const avatarElement = new MenuElement("avatar"); -// Windows and screens -export const avatarWindow = new AvatarWindow(); -const loginWindow = new LoginWindow(); -const global_chat = new GlobalChat(); - - -// Actions UI -document.getElementById("login").addEventListener("click", () => { - // Toggle login window visibility - if (loginWindow.main && loginWindow.main.style.display !== "none") { - loginWindow.hide(); - } else { - loginWindow.show(); - } -}); - -document.getElementById("global_chat").addEventListener("click", () => { - // Toggle global chat visibility - if (global_chat.main && global_chat.main.style.display !== "none") { - global_chat.hide(); - } else { - global_chat.show(); - } -}); - - -document.getElementById("avatar").addEventListener("click", () => { - // Toggle avatar window visibility - if (avatarWindow.main && avatarWindow.main.style.display !== "none") { - avatarWindow.hide(); - } else { - avatarWindow.show(); - } -}); +// Start the application when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => new App()); +} else { + new App(); +} diff --git a/srcs/frontend/src/avatar.js b/srcs/frontend/src/avatar.js index 81777ae..3201dad 100644 --- a/srcs/frontend/src/avatar.js +++ b/srcs/frontend/src/avatar.js @@ -1,133 +1,168 @@ -import {fenetre} from "./windows.js"; +import { Window } from './windows.js'; +import { API, STORAGE_KEYS, CSS } from './config.js'; +import { eventBus, Events } from './events.js'; -export class AvatarWindow extends fenetre { +/** + * Avatar management window + * Allows viewing and modifying the user's avatar + */ +export class AvatarWindow extends Window { constructor() { - super(360, 320, "Avatar"); + 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.avatarPreview = document.createElement("img"); - this.avatarPreview.style.width = "120px"; - this.avatarPreview.style.height = "120px"; - this.avatarPreview.style.objectFit = "cover"; - this.avatarPreview.style.borderRadius = "50%"; - this.avatarPreview.style.border = "2px solid #fff"; + this.preview = this.createElement('img', CSS.AVATAR_PREVIEW, { + alt: 'Avatar' + }); + // Username display + this.username = this.createElement('div', CSS.AVATAR_USERNAME); - this.fileInput = document.createElement("input"); - this.fileInput.type = "file"; - this.fileInput.accept = "image/*"; - // Hide the raw file input to keep only one visible control - this.fileInput.style.display = "none"; + // Hidden file input + this.fileInput = this.createElement('input', 'avatar__file-input', { + type: 'file', + accept: 'image/*' + }); - this.chooseBtn = document.createElement("button"); - this.chooseBtn.textContent = "Choisir image"; + // Controls + this.controls = this.createElement('div', CSS.AVATAR_CONTROLS); - this.saveBtn = document.createElement("button"); - this.saveBtn.textContent = "Enregistrer avatar"; + this.chooseBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { + text: 'Choose image' + }); - // Refresh button to re-fetch avatar from server - this.refreshBtn = document.createElement("button"); - this.refreshBtn.textContent = "Rafraîchir photo"; + this.saveBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { + text: 'Save avatar' + }); - this.message = document.createElement("div"); - this.message.style.fontSize = "0.9em"; + 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.avatarPreview, + this.preview, + this.username, this.fileInput, - this.chooseBtn, - this.saveBtn, - this.refreshBtn, + this.controls, this.message ); - - this.applyStyles(); - this.bindEvents(); - // Load current avatar on initialization - this.getPhoto(); - } - - applyStyles() { - // Center avatar in the window body - this.body.style.display = "flex"; - this.body.style.flexDirection = "column"; - this.body.style.alignItems = "center"; - this.body.style.gap = "12px"; - // Style helpers - this.avatarPreview.style.boxShadow = "0 0 8px rgba(0,0,0,0.5)"; - this.chooseBtn.style.padding = "6px 12px"; - this.chooseBtn.style.cursor = "pointer"; - this.saveBtn.style.padding = "6px 12px"; - this.saveBtn.style.cursor = "pointer"; } + /** + * Attaches event handlers + */ bindEvents() { - this.fileInput.addEventListener("change", (e) => { - const file = e.target.files && e.target.files[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = (ev) => { - this.avatarPreview.src = ev.target.result; - }; - reader.readAsDataURL(file); - }); - - this.chooseBtn.addEventListener("click", () => { - // trigger file input - this.fileInput.click(); - }); - - this.saveBtn.addEventListener("click", () => { - // Send the selected photo to the server - this.postPhoto(); - }); - - // Bind refresh button to re-fetch avatar from server - this.refreshBtn.addEventListener("click", () => { - this.getPhoto(); - }); + 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()); } - async getPhoto(){ - console.log("getPhoto launched..."); - const token = localStorage.getItem("auth_token"); + + /** + * 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 auth token found; skipping avatar fetch"); + 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/me", { - method: "GET", + const response = await fetch(API.AVATAR.GET, { + method: 'GET', headers: { - "Authorization": `Bearer ${token}` + 'Authorization': `Bearer ${token}` } }); + if (!response.ok) { - console.warn("Failed to fetch avatar (status", response.status, ")"); + console.warn('Failed to load avatar, status:', response.status); return; } + const data = await response.json(); - console.log(data); - if (data && data.avatar_url) { - this.avatarPreview.src = data.avatar_url; + + if (data?.avatar_url) { + this.preview.src = data.avatar_url; } else { - console.warn("Avatar URL not found in response"); + console.warn('Avatar URL not found in response'); } - } catch (err) { - console.error("Error while fetching avatar:", err); + } catch (error) { + console.error('Error loading avatar:', error); } } - - async postPhoto(){ - console.log("postPhoto launched..."); - const token = localStorage.getItem("auth_token"); + + /** + * Uploads avatar to the server + */ + async uploadAvatar() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); if (!token) { - this.message.textContent = "No auth. plz connect."; - this.message.style.color = "#f00"; + this.showMessage('You must be logged in', 'error'); return; } - const file = this.fileInput.files && this.fileInput.files[0]; + + const file = this.fileInput.files?.[0]; if (!file) { - this.message.textContent = "take image before"; - this.message.style.color = "#f00"; + this.showMessage('Select an image first', 'error'); return; } @@ -135,31 +170,52 @@ export class AvatarWindow extends fenetre { formData.append('avatar', file); try { - const response = await fetch('/api/avatar/upload', { + 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 err = data?.error || data?.message || 'Upload failed'; - this.message.textContent = err; - this.message.style.color = '#f00'; + const errorMsg = data?.error || data?.message || 'Upload failed'; + this.showMessage(errorMsg, 'error'); return; } - if (data && data.avatar_url) { - this.avatarPreview.src = data.avatar_url; + + if (data?.avatar_url) { + this.preview.src = data.avatar_url; } - this.message.textContent = 'Avatar enregistré !'; - this.message.style.color = '#3cff01'; - } catch (err) { - console.error('Avatar upload error:', err); - this.message.textContent = 'Erreur lors de l’envoi'; - this.message.style.color = '#f00'; + + this.showMessage('Avatar saved!', 'success'); + eventBus.emit(Events.AVATAR_UPDATED, { url: data?.avatar_url }); + + } catch (error) { + console.error('Avatar upload error:', error); + this.showMessage('Upload error', 'error'); } } + /** + * Displays a feedback message + * @param {string} text - Message text + * @param {'success'|'error'|'info'} type - Message type + */ + showMessage(text, type = 'info') { + this.message.textContent = text; + this.message.className = CSS.MESSAGE; + if (type === 'success') { + this.message.classList.add(CSS.MESSAGE_SUCCESS); + } else if (type === 'error') { + this.message.classList.add(CSS.MESSAGE_ERROR); + } else { + this.message.classList.add(CSS.MESSAGE_INFO); + } + } } diff --git a/srcs/frontend/src/config.js b/srcs/frontend/src/config.js new file mode 100644 index 0000000..646575f --- /dev/null +++ b/srcs/frontend/src/config.js @@ -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' +}; diff --git a/srcs/frontend/src/element.js b/srcs/frontend/src/element.js index 91f08da..a01abda 100644 --- a/srcs/frontend/src/element.js +++ b/srcs/frontend/src/element.js @@ -1,38 +1,114 @@ -export class Element { - constructor(id) { - this.element = document.getElementById(id); - // Debug: log hover events for the element with a minimal, clear message - this.element.addEventListener("mouseenter", () => { - console.log("Hover: " + id); - }); - this.element.addEventListener("mouseleave", () => { - console.log("Leave: " + id); - }); - } +/** + * 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; } -export class MenuElement extends Element { - constructor(id) { - super(id); - // Basic click feedback - this.element.addEventListener("click", () => { - console.log("Clicked: " + id); - }); - // Simple hover styling for menu items to improve clarity - this.element.addEventListener("mouseenter", () => { - this.element.style.backgroundColor = "lightgrey"; - this.element.style.fontSize = "1.2em"; - this.element.style.cursor = "pointer"; - }); - this.element.addEventListener("mouseleave", () => { - // Reset styles when not hovered - this.element.style.backgroundColor = ""; - this.element.style.fontSize = ""; - this.element.style.cursor = ""; - // Cancel any running animations for a crisp reset (defensive) - if (this.element.getAnimations) { - this.element.getAnimations().forEach(animation => animation.cancel()); - } - }); - } +/** + * Selects an element by its data-attribute + * @param {string} attr - Attribute name (without 'data-') + * @param {string} value - Value to search for + * @param {HTMLElement} [parent=document] - Parent element + * @returns {HTMLElement|null} + */ +export function findByData(attr, value, parent = document) { + return parent.querySelector(`[data-${attr}="${value}"]`); +} + +/** + * Selects all elements by their data-attribute + * @param {string} attr - Attribute name (without 'data-') + * @param {string} [value] - Value to search for (optional) + * @param {HTMLElement} [parent=document] - Parent element + * @returns {HTMLElement[]} + */ +export function findAllByData(attr, value, parent = document) { + const selector = value ? `[data-${attr}="${value}"]` : `[data-${attr}]`; + return Array.from(parent.querySelectorAll(selector)); +} + +/** + * Adds or removes a class based on a condition + * @param {HTMLElement} element + * @param {string} className + * @param {boolean} condition + */ +export function toggleClass(element, className, condition) { + if (condition) { + element.classList.add(className); + } else { + element.classList.remove(className); + } +} + +/** + * Escapes HTML to prevent XSS + * @param {string} text + * @returns {string} + */ +export function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; } diff --git a/srcs/frontend/src/events.js b/srcs/frontend/src/events.js new file mode 100644 index 0000000..5bb0aa6 --- /dev/null +++ b/srcs/frontend/src/events.js @@ -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' +}; diff --git a/srcs/frontend/src/friends.js b/srcs/frontend/src/friends.js new file mode 100644 index 0000000..e262af5 --- /dev/null +++ b/srcs/frontend/src/friends.js @@ -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); + } + } +} diff --git a/srcs/frontend/src/global_chat.js b/srcs/frontend/src/global_chat.js index 8294fce..7983b12 100644 --- a/srcs/frontend/src/global_chat.js +++ b/srcs/frontend/src/global_chat.js @@ -1,200 +1,199 @@ -import {fenetre} from "./windows.js"; -export class GlobalChat extends fenetre { +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(320, 240, "Global Chat"); + super({ + name: 'chat', + title: 'Global Chat', + cssClasses: ['chat'] + }); - // Creation of the elements - this.output = document.createElement("div"); - this.output.className = "chat-output"; - - this.input = document.createElement("input"); - this.input.type = "text"; - this.input.placeholder = "Tape ton message..."; - this.input.className = "chat-input"; - - this.sendButton = document.createElement("button"); - this.sendButton.textContent = "Envoyer"; - this.sendButton.className = "send-btn"; - - this.inputContainer = document.createElement("div"); - this.inputContainer.className = "input-container"; - this.inputContainer.append(this.input, this.sendButton); - - this.body.append(this.output, this.inputContainer); - - this.applyStyles(); - this.applyEvents(); - // Connection to the global chat is started via dedicated controls + this.socket = null; this.connected = false; - this.createConnectControls(); + + this.buildUI(); + this.bindEvents(); } - // Create the controls (Connect / Reconnect) in the chat window - createConnectControls() { - this.controls = document.createElement("div"); - this.controls.style.display = "flex"; - this.controls.style.gap = "8px"; - this.controls.style.marginTop = "6px"; + /** + * Builds the user interface + */ + buildUI() { + // Message display area + this.output = this.createElement('div', CSS.CHAT_OUTPUT); - this.connectButton = document.createElement("button"); - this.connectButton.textContent = "Connecter"; - this.connectButton.style.padding = "6px 12px"; - this.connectButton.style.background = "#28a745"; - this.connectButton.style.color = "white"; - this.connectButton.style.border = "none"; - this.connectButton.style.borderRadius = "6px"; - this.connectButton.style.cursor = "pointer"; + // Input container + this.inputContainer = this.createElement('div', 'chat__input-container'); - this.reconnectButton = document.createElement("button"); - this.reconnectButton.textContent = "Reconnecter"; - this.reconnectButton.style.padding = "6px 12px"; - this.reconnectButton.style.background = "#007bff"; - this.reconnectButton.style.color = "white"; - this.reconnectButton.style.border = "none"; - this.reconnectButton.style.borderRadius = "6px"; - this.reconnectButton.style.cursor = "pointer"; + this.input = this.createElement('input', [CSS.INPUT, CSS.CHAT_INPUT], { + type: 'text', + placeholder: 'Type your message...' + }); - this.controls.append(this.connectButton, this.reconnectButton); - this.body.appendChild(this.controls); + this.sendBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { + text: 'Send' + }); - this.connectButton.addEventListener("click", () => this.connect_sockio_global_chat()); - this.reconnectButton.addEventListener("click", () => this.reconnect_sockio_global_chat()); + 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); } - async reconnect_sockio_global_chat() { - // Disconnect and reconnect the socket if necessary - if (this.socket) { - try { - this.socket.close(); - } catch (e) { - // ignore - } - this.socket = null; - this.output.innerHTML += '
Reconnexion en cours...
'; - } - this.connected = false; - await this.connect_sockio_global_chat(); - } + /** + * Attaches event handlers + */ + bindEvents() { + this.sendBtn.addEventListener('click', () => this.sendMessage()); + this.connectBtn.addEventListener('click', () => this.connect()); + this.reconnectBtn.addEventListener('click', () => this.reconnect()); - applyStyles() { - // Main container in flex collumn - this.body.style.display = "flex"; - this.body.style.flexDirection = "column"; - this.body.style.height = "100%"; - this.body.style.padding = "10px"; - this.body.style.boxSizing = "border-box"; - this.body.style.gap = "10px"; - - // Messages zone - this.output.style.flex = "1"; - this.output.style.overflowY = "auto"; - this.output.style.padding = "8px"; - this.output.style.background = "#7fb8f1"; - this.output.style.borderRadius = "6px"; - this.output.style.display = "flex"; - this.output.style.flexDirection = "column"; - this.output.style.gap = "10px"; - - // Input container + button - this.inputContainer.style.display = "flex"; - this.inputContainer.style.gap = "8px"; - this.inputContainer.style.paddingTop = "8px"; - - // Input - this.input.style.flex = "1"; - this.input.style.padding = "8px 12px"; - this.input.style.border = "1px solid #ccc"; - this.input.style.borderRadius = "6px"; - this.input.style.fontSize = "14px"; - - // Sender button - this.sendButton.style.padding = "8px 16px"; - this.sendButton.style.background = "#0066cc"; - this.sendButton.style.color = "white"; - this.sendButton.style.border = "none"; - this.sendButton.style.borderRadius = "6px"; - this.sendButton.style.cursor = "pointer"; - this.sendButton.style.fontWeight = "500"; - } - - applyEvents() { - // Send with the button - this.sendButton.addEventListener("click", () => this.sendMessage()); - - // Send with Enter key - this.input.addEventListener("keypress", (e) => { - if (e.key === "Enter" && !e.shiftKey) { + // Send with Enter + this.input.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); } }); } - // Send current message via Socket.IO + /** + * 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 + */ + addChatMessage(username, content, isOwn = false) { + const msg = this.createElement('div', CSS.CHAT_MESSAGE); + + if (isOwn) { + msg.classList.add('chat__message--own'); + } + + msg.innerHTML = `${this.escapeHtml(username)}: ${this.escapeHtml(content)}`; + this.output.appendChild(msg); + this.scrollToBottom(); + } + + /** + * Escapes HTML to prevent XSS + * @param {string} text + * @returns {string} + */ + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + /** + * Scrolls the message area to the bottom + */ + scrollToBottom() { + this.output.scrollTop = this.output.scrollHeight; + } + + /** + * Sends a message + */ sendMessage() { const content = this.input.value.trim(); if (!content) return; - // Envoi au backend si connecté - if (this.socket && this.socket.connected) { - this.socket.emit("chat-message", { content }); - } else { - this.output.innerHTML += '
Erreur: vous n\'êtes pas connecté au chat global
'; + if (!this.socket?.connected) { + this.addSystemMessage('Error: you are not connected to the global chat', 'error'); return; } - // Immediate display in the interface pour user feedback - const div = document.createElement("div"); - div.className = "chat-message"; - div.innerHTML = `Moi: ${content}`; - this.output.appendChild(div); - this.output.scrollTop = this.output.scrollHeight; - - // Reset input - this.input.value = ""; + this.socket.emit('chat-message', { content }); + this.addChatMessage('Me', content, true); + this.input.value = ''; } - async connect_sockio_global_chat() { - const token = localStorage.getItem("auth_token"); + /** + * Reconnects to the server + */ + async reconnect() { + if (this.socket) { + try { + this.socket.close(); + } catch (e) { + // Ignore + } + this.socket = null; + } - console.log("Tentative de connexion Socket.IO"); - console.log("→ Token trouvé ? ", !!token); - if (token) console.log("→ Token (début) : ", token.substring(0, 20) + "..."); + 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) { - console.error("→ ERREUR : Aucun token dans localStorage → connexion impossible"); - this.output.innerHTML += '
Erreur : vous devez être connecté pour utiliser le chat global
'; + this.addSystemMessage('Error: you must be logged in to use the global chat', 'error'); return; } - // If already connected, dont retry - if (this.socket && this.socket.connected) { - this.output.innerHTML += '
Déjà connecté au chat global
'; + if (this.socket?.connected) { + this.addSystemMessage('Already connected to global chat'); return; } - if (!window.io) { - const script = document.createElement("script"); - script.src = "/socket.io/socket.io.js"; - document.head.appendChild(script); - - await new Promise(resolve => { - script.onload = () => { - console.log("Script socket.io chargé depuis le backend"); - resolve(); - }; - script.onerror = () => console.error("Impossible de charger socket.io depuis le backend"); - }); - } + // Load Socket.IO if needed + await this.loadSocketIO(); const ioConfig = { auth: { token }, reconnection: true, reconnectionAttempts: 5, reconnectionDelay: 1000, - transports: ["websocket", "polling"] + transports: ['websocket', 'polling'] }; - // Optional: connect from an alternative port (ex: to dodge the proxy) + + // Optional alternative port const altPort = window.GLOBAL_CHAT_ALT_PORT; if (altPort) { const host = location.hostname || 'localhost'; @@ -203,28 +202,59 @@ export class GlobalChat extends fenetre { this.socket = io(ioConfig); } - this.socket.on("connect", () => { - console.log("→ SOCKET CONNECTÉ ! ID =", this.socket.id); - this.output.innerHTML += '
Connecté au chat global ✓
'; + 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("→ Erreur de connexion socket :", err.message); - this.output.innerHTML += `
Erreur connexion chat : ${err.message}
`; + 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("→ Déconnecté :", reason); - this.output.innerHTML += `
Déconnecté du chat (${reason})
`; + this.socket.on('disconnect', (reason) => { + console.log('Socket disconnected:', reason); + this.connected = false; + this.addSystemMessage(`Disconnected (${reason})`); + eventBus.emit(Events.CHAT_DISCONNECTED, { reason }); }); - // Messages reception - this.socket.on("chat-message", (msg) => { - const div = document.createElement("div"); - div.className = "chat-message"; - div.innerHTML = `${msg.username}: ${msg.content}`; - this.output.appendChild(div); - this.output.scrollTop = this.output.scrollHeight; + this.socket.on('chat-message', (msg) => { + this.addChatMessage(msg.username, msg.content); + eventBus.emit(Events.CHAT_MESSAGE_RECEIVED, msg); }); } } diff --git a/srcs/frontend/src/index.html b/srcs/frontend/src/index.html index 54e2f12..0003109 100644 --- a/srcs/frontend/src/index.html +++ b/srcs/frontend/src/index.html @@ -5,30 +5,22 @@ scribl.lidl_edition - + + + -

scribl.lidl_edition

+

scribl.lidl_edition

- -