@@ -6,6 +6,7 @@ import authRouter from './routes/auth.js';
|
|||||||
import chatRouter from './routes/global_chat.js';
|
import chatRouter from './routes/global_chat.js';
|
||||||
import gameRoomRouter from './routes/game_room.js';
|
import gameRoomRouter from './routes/game_room.js';
|
||||||
import avatarRouter from './routes/avatar.js';
|
import avatarRouter from './routes/avatar.js';
|
||||||
|
import friendsRouter from './routes/friends.js';
|
||||||
import {waitForDb, createTables, ensureOauthClient} from './db.js';
|
import {waitForDb, createTables, ensureOauthClient} from './db.js';
|
||||||
import setupSocketIO from './services/socket.js';
|
import setupSocketIO from './services/socket.js';
|
||||||
import avatarService from './services/avatar.js';
|
import avatarService from './services/avatar.js';
|
||||||
@@ -43,6 +44,7 @@ async function startServer()
|
|||||||
app.use('/api/global_chat', chatRouter);
|
app.use('/api/global_chat', chatRouter);
|
||||||
app.use('/api/rooms', gameRoomRouter);
|
app.use('/api/rooms', gameRoomRouter);
|
||||||
app.use('/api/avatar', avatarRouter);
|
app.use('/api/avatar', avatarRouter);
|
||||||
|
app.use('/api/friends', friendsRouter);
|
||||||
app.get('/api', (req, res) => res.send('Backend running'));
|
app.get('/api', (req, res) => res.send('Backend running'));
|
||||||
|
|
||||||
server.listen(3001, () =>
|
server.listen(3001, () =>
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import chatService from './global_chat.js';
|
import chatService from './global_chat.js';
|
||||||
|
import friendsService from './friends.js';
|
||||||
|
|
||||||
function setupSocketIO(io)
|
function setupSocketIO(io)
|
||||||
{
|
{
|
||||||
@@ -21,19 +22,36 @@ function setupSocketIO(io)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
io.on('connection', (socket) =>
|
io.on('connection', async (socket) =>
|
||||||
{
|
{
|
||||||
console.log(`User connected: ${socket.user.username}`);
|
console.log(`User connected: ${socket.user.username}`);
|
||||||
|
|
||||||
socket.join('general-chat');
|
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) =>
|
socket.on('chat-message', async(data) =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const message = await chatService.saveMessage(socket.user.userId, data.content);
|
const message = await chatService.saveMessage(socket.user.userId, data.content);
|
||||||
io.to('general-chat').emit('chat-message',
|
socket.broadcast.to('general-chat').emit('chat-message',
|
||||||
{
|
{
|
||||||
id:message.id,
|
id: message.id,
|
||||||
|
sender_id: socket.user.userId,
|
||||||
username: socket.user.username,
|
username: socket.user.username,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
created_at: message.created_at
|
created_at: message.created_at
|
||||||
|
|||||||
+83
-48
@@ -1,51 +1,86 @@
|
|||||||
import {Element, MenuElement} from "./element.js";
|
/**
|
||||||
import {LoginWindow} from "./login.js";
|
* Application entry point
|
||||||
import { GlobalChat } from "./global_chat.js";
|
* Initializes windows and handles menu interactions
|
||||||
import { AvatarWindow } from "./avatar.js";
|
*/
|
||||||
|
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)
|
// Start the application when DOM is ready
|
||||||
const menuElement = new Element("menu");
|
if (document.readyState === 'loading') {
|
||||||
const loginElement = new MenuElement("login");
|
document.addEventListener('DOMContentLoaded', () => new App());
|
||||||
const registeredElement = new MenuElement("registered");
|
} else {
|
||||||
const explorerElement = new MenuElement("explorer");
|
new App();
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
+161
-105
@@ -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() {
|
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
|
// Avatar preview
|
||||||
this.avatarPreview = document.createElement("img");
|
this.preview = this.createElement('img', CSS.AVATAR_PREVIEW, {
|
||||||
this.avatarPreview.style.width = "120px";
|
alt: 'Avatar'
|
||||||
this.avatarPreview.style.height = "120px";
|
});
|
||||||
this.avatarPreview.style.objectFit = "cover";
|
|
||||||
this.avatarPreview.style.borderRadius = "50%";
|
|
||||||
this.avatarPreview.style.border = "2px solid #fff";
|
|
||||||
|
|
||||||
|
// Username display
|
||||||
|
this.username = this.createElement('div', CSS.AVATAR_USERNAME);
|
||||||
|
|
||||||
this.fileInput = document.createElement("input");
|
// Hidden file input
|
||||||
this.fileInput.type = "file";
|
this.fileInput = this.createElement('input', 'avatar__file-input', {
|
||||||
this.fileInput.accept = "image/*";
|
type: 'file',
|
||||||
// Hide the raw file input to keep only one visible control
|
accept: 'image/*'
|
||||||
this.fileInput.style.display = "none";
|
});
|
||||||
|
|
||||||
this.chooseBtn = document.createElement("button");
|
// Controls
|
||||||
this.chooseBtn.textContent = "Choisir image";
|
this.controls = this.createElement('div', CSS.AVATAR_CONTROLS);
|
||||||
|
|
||||||
this.saveBtn = document.createElement("button");
|
this.chooseBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
|
||||||
this.saveBtn.textContent = "Enregistrer avatar";
|
text: 'Choose image'
|
||||||
|
});
|
||||||
|
|
||||||
// Refresh button to re-fetch avatar from server
|
this.saveBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
|
||||||
this.refreshBtn = document.createElement("button");
|
text: 'Save avatar'
|
||||||
this.refreshBtn.textContent = "Rafraîchir photo";
|
});
|
||||||
|
|
||||||
this.message = document.createElement("div");
|
this.refreshBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
|
||||||
this.message.style.fontSize = "0.9em";
|
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.body.append(
|
||||||
this.avatarPreview,
|
this.preview,
|
||||||
|
this.username,
|
||||||
this.fileInput,
|
this.fileInput,
|
||||||
this.chooseBtn,
|
this.controls,
|
||||||
this.saveBtn,
|
|
||||||
this.refreshBtn,
|
|
||||||
this.message
|
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() {
|
bindEvents() {
|
||||||
this.fileInput.addEventListener("change", (e) => {
|
this.fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
|
||||||
const file = e.target.files && e.target.files[0];
|
this.chooseBtn.addEventListener('click', () => this.fileInput.click());
|
||||||
if (!file) return;
|
this.saveBtn.addEventListener('click', () => this.uploadAvatar());
|
||||||
const reader = new FileReader();
|
this.refreshBtn.addEventListener('click', () => this.loadAvatar());
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
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) {
|
if (!token) {
|
||||||
console.log("No auth token found; skipping avatar fetch");
|
console.log('No token, skipping avatar load');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract username from JWT token
|
||||||
|
const tokenData = this.decodeToken(token);
|
||||||
|
if (tokenData?.username) {
|
||||||
|
this.username.textContent = tokenData.username;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/avatar/me", {
|
const response = await fetch(API.AVATAR.GET, {
|
||||||
method: "GET",
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.warn("Failed to fetch avatar (status", response.status, ")");
|
console.warn('Failed to load avatar, status:', response.status);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log(data);
|
|
||||||
if (data && data.avatar_url) {
|
if (data?.avatar_url) {
|
||||||
this.avatarPreview.src = data.avatar_url;
|
this.preview.src = data.avatar_url;
|
||||||
} else {
|
} else {
|
||||||
console.warn("Avatar URL not found in response");
|
console.warn('Avatar URL not found in response');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.error("Error while fetching avatar:", err);
|
console.error('Error loading avatar:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async postPhoto(){
|
/**
|
||||||
console.log("postPhoto launched...");
|
* Uploads avatar to the server
|
||||||
const token = localStorage.getItem("auth_token");
|
*/
|
||||||
|
async uploadAvatar() {
|
||||||
|
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
this.message.textContent = "No auth. plz connect.";
|
this.showMessage('You must be logged in', 'error');
|
||||||
this.message.style.color = "#f00";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const file = this.fileInput.files && this.fileInput.files[0];
|
|
||||||
|
const file = this.fileInput.files?.[0];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
this.message.textContent = "take image before";
|
this.showMessage('Select an image first', 'error');
|
||||||
this.message.style.color = "#f00";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,31 +170,52 @@ export class AvatarWindow extends fenetre {
|
|||||||
formData.append('avatar', file);
|
formData.append('avatar', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/avatar/upload', {
|
this.showMessage('Uploading...', 'info');
|
||||||
|
|
||||||
|
const response = await fetch(API.AVATAR.UPLOAD, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const err = data?.error || data?.message || 'Upload failed';
|
const errorMsg = data?.error || data?.message || 'Upload failed';
|
||||||
this.message.textContent = err;
|
this.showMessage(errorMsg, 'error');
|
||||||
this.message.style.color = '#f00';
|
|
||||||
return;
|
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';
|
this.showMessage('Avatar saved!', 'success');
|
||||||
} catch (err) {
|
eventBus.emit(Events.AVATAR_UPDATED, { url: data?.avatar_url });
|
||||||
console.error('Avatar upload error:', err);
|
|
||||||
this.message.textContent = 'Erreur lors de l’envoi';
|
} catch (error) {
|
||||||
this.message.style.color = '#f00';
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
};
|
||||||
+111
-35
@@ -1,38 +1,114 @@
|
|||||||
export class Element {
|
/**
|
||||||
constructor(id) {
|
* DOM element utilities
|
||||||
this.element = document.getElementById(id);
|
* This module provides helper functions for creating elements
|
||||||
// Debug: log hover events for the element with a minimal, clear message
|
* without depending on specific HTML IDs
|
||||||
this.element.addEventListener("mouseenter", () => {
|
*/
|
||||||
console.log("Hover: " + id);
|
|
||||||
});
|
/**
|
||||||
this.element.addEventListener("mouseleave", () => {
|
* Creates a DOM element with options
|
||||||
console.log("Leave: " + id);
|
* @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) {
|
* Selects an element by its data-attribute
|
||||||
super(id);
|
* @param {string} attr - Attribute name (without 'data-')
|
||||||
// Basic click feedback
|
* @param {string} value - Value to search for
|
||||||
this.element.addEventListener("click", () => {
|
* @param {HTMLElement} [parent=document] - Parent element
|
||||||
console.log("Clicked: " + id);
|
* @returns {HTMLElement|null}
|
||||||
});
|
*/
|
||||||
// Simple hover styling for menu items to improve clarity
|
export function findByData(attr, value, parent = document) {
|
||||||
this.element.addEventListener("mouseenter", () => {
|
return parent.querySelector(`[data-${attr}="${value}"]`);
|
||||||
this.element.style.backgroundColor = "lightgrey";
|
}
|
||||||
this.element.style.fontSize = "1.2em";
|
|
||||||
this.element.style.cursor = "pointer";
|
/**
|
||||||
});
|
* Selects all elements by their data-attribute
|
||||||
this.element.addEventListener("mouseleave", () => {
|
* @param {string} attr - Attribute name (without 'data-')
|
||||||
// Reset styles when not hovered
|
* @param {string} [value] - Value to search for (optional)
|
||||||
this.element.style.backgroundColor = "";
|
* @param {HTMLElement} [parent=document] - Parent element
|
||||||
this.element.style.fontSize = "";
|
* @returns {HTMLElement[]}
|
||||||
this.element.style.cursor = "";
|
*/
|
||||||
// Cancel any running animations for a crisp reset (defensive)
|
export function findAllByData(attr, value, parent = document) {
|
||||||
if (this.element.getAnimations) {
|
const selector = value ? `[data-${attr}="${value}"]` : `[data-${attr}]`;
|
||||||
this.element.getAnimations().forEach(animation => animation.cancel());
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+216
-170
@@ -1,200 +1,202 @@
|
|||||||
import {fenetre} from "./windows.js";
|
import { Window } from './windows.js';
|
||||||
export class GlobalChat extends fenetre {
|
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() {
|
constructor() {
|
||||||
super(320, 240, "Global Chat");
|
super({
|
||||||
|
name: 'chat',
|
||||||
|
title: 'Global Chat',
|
||||||
|
cssClasses: ['chat']
|
||||||
|
});
|
||||||
|
|
||||||
// Creation of the elements
|
this.socket = null;
|
||||||
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.connected = false;
|
this.connected = false;
|
||||||
this.createConnectControls();
|
this.friendIds = new Set();
|
||||||
|
|
||||||
|
this.buildUI();
|
||||||
|
this.bindEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the controls (Connect / Reconnect) in the chat window
|
/**
|
||||||
createConnectControls() {
|
* Builds the user interface
|
||||||
this.controls = document.createElement("div");
|
*/
|
||||||
this.controls.style.display = "flex";
|
buildUI() {
|
||||||
this.controls.style.gap = "8px";
|
// Message display area
|
||||||
this.controls.style.marginTop = "6px";
|
this.output = this.createElement('div', CSS.CHAT_OUTPUT);
|
||||||
|
|
||||||
this.connectButton = document.createElement("button");
|
// Input container
|
||||||
this.connectButton.textContent = "Connecter";
|
this.inputContainer = this.createElement('div', 'chat__input-container');
|
||||||
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";
|
|
||||||
|
|
||||||
this.reconnectButton = document.createElement("button");
|
this.input = this.createElement('input', [CSS.INPUT, CSS.CHAT_INPUT], {
|
||||||
this.reconnectButton.textContent = "Reconnecter";
|
type: 'text',
|
||||||
this.reconnectButton.style.padding = "6px 12px";
|
placeholder: 'Type your message...'
|
||||||
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.controls.append(this.connectButton, this.reconnectButton);
|
this.sendBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
|
||||||
this.body.appendChild(this.controls);
|
text: 'Send'
|
||||||
|
});
|
||||||
|
|
||||||
this.connectButton.addEventListener("click", () => this.connect_sockio_global_chat());
|
this.inputContainer.append(this.input, this.sendBtn);
|
||||||
this.reconnectButton.addEventListener("click", () => this.reconnect_sockio_global_chat());
|
|
||||||
|
// 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
|
* Attaches event handlers
|
||||||
if (this.socket) {
|
*/
|
||||||
try {
|
bindEvents() {
|
||||||
this.socket.close();
|
this.sendBtn.addEventListener('click', () => this.sendMessage());
|
||||||
} catch (e) {
|
this.connectBtn.addEventListener('click', () => this.connect());
|
||||||
// ignore
|
this.reconnectBtn.addEventListener('click', () => this.reconnect());
|
||||||
}
|
|
||||||
this.socket = null;
|
|
||||||
this.output.innerHTML += '<div class="system">Reconnexion en cours...</div>';
|
|
||||||
}
|
|
||||||
this.connected = false;
|
|
||||||
await this.connect_sockio_global_chat();
|
|
||||||
}
|
|
||||||
|
|
||||||
applyStyles() {
|
// Send with Enter
|
||||||
// Main container in flex collumn
|
this.input.addEventListener('keypress', (e) => {
|
||||||
this.body.style.display = "flex";
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
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) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.sendMessage();
|
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
|
||||||
|
* @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() {
|
sendMessage() {
|
||||||
const content = this.input.value.trim();
|
const content = this.input.value.trim();
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
||||||
// Envoi au backend si connecté
|
if (!this.socket?.connected) {
|
||||||
if (this.socket && this.socket.connected) {
|
this.addSystemMessage('Error: you are not connected to the global chat', 'error');
|
||||||
this.socket.emit("chat-message", { content });
|
|
||||||
} else {
|
|
||||||
this.output.innerHTML += '<div class="system error">Erreur: vous n\'êtes pas connecté au chat global</div>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Immediate display in the interface pour user feedback
|
this.socket.emit('chat-message', { content });
|
||||||
const div = document.createElement("div");
|
this.addChatMessage('Me', content, true);
|
||||||
div.className = "chat-message";
|
this.input.value = '';
|
||||||
div.innerHTML = `<strong>Moi:</strong> ${content}`;
|
|
||||||
this.output.appendChild(div);
|
|
||||||
this.output.scrollTop = this.output.scrollHeight;
|
|
||||||
|
|
||||||
// Reset input
|
|
||||||
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");
|
this.connected = false;
|
||||||
console.log("→ Token trouvé ? ", !!token);
|
this.addSystemMessage('Reconnecting...');
|
||||||
if (token) console.log("→ Token (début) : ", token.substring(0, 20) + "...");
|
await this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects to the Socket.IO server
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.error("→ ERREUR : Aucun token dans localStorage → connexion impossible");
|
this.addSystemMessage('Error: you must be logged in to use the global chat', 'error');
|
||||||
this.output.innerHTML += '<div class="system">Erreur : vous devez être connecté pour utiliser le chat global</div>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If already connected, dont retry
|
if (this.socket?.connected) {
|
||||||
if (this.socket && this.socket.connected) {
|
this.addSystemMessage('Already connected to global chat');
|
||||||
this.output.innerHTML += '<div class="system">Déjà connecté au chat global</div>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.io) {
|
// Load Socket.IO if needed
|
||||||
const script = document.createElement("script");
|
await this.loadSocketIO();
|
||||||
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");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ioConfig = {
|
const ioConfig = {
|
||||||
auth: { token },
|
auth: { token },
|
||||||
reconnection: true,
|
reconnection: true,
|
||||||
reconnectionAttempts: 5,
|
reconnectionAttempts: 5,
|
||||||
reconnectionDelay: 1000,
|
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;
|
const altPort = window.GLOBAL_CHAT_ALT_PORT;
|
||||||
if (altPort) {
|
if (altPort) {
|
||||||
const host = location.hostname || 'localhost';
|
const host = location.hostname || 'localhost';
|
||||||
@@ -203,28 +205,72 @@ export class GlobalChat extends fenetre {
|
|||||||
this.socket = io(ioConfig);
|
this.socket = io(ioConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.socket.on("connect", () => {
|
this.setupSocketListeners();
|
||||||
console.log("→ SOCKET CONNECTÉ ! ID =", this.socket.id);
|
}
|
||||||
this.output.innerHTML += '<div class="system">Connecté au chat global ✓</div>';
|
|
||||||
|
/**
|
||||||
|
* 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) => {
|
this.socket.on('connect_error', (err) => {
|
||||||
console.error("→ Erreur de connexion socket :", err.message);
|
console.error('Socket connection error:', err.message);
|
||||||
this.output.innerHTML += `<div class="system error">Erreur connexion chat : ${err.message}</div>`;
|
this.addSystemMessage(`Connection error: ${err.message}`, 'error');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on("disconnect", (reason) => {
|
this.socket.on('disconnect', (reason) => {
|
||||||
console.log("→ Déconnecté :", reason);
|
console.log('Socket disconnected:', reason);
|
||||||
this.output.innerHTML += `<div class="system">Déconnecté du chat (${reason})</div>`;
|
this.connected = false;
|
||||||
|
this.addSystemMessage(`Disconnected (${reason})`);
|
||||||
|
eventBus.emit(Events.CHAT_DISCONNECTED, { reason });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Messages reception
|
// Handle initial data (recent messages + friend IDs)
|
||||||
this.socket.on("chat-message", (msg) => {
|
this.socket.on('chat-init', (data) => {
|
||||||
const div = document.createElement("div");
|
console.log('Received chat init data:', data.messages.length, 'messages');
|
||||||
div.className = "chat-message";
|
this.friendIds = new Set(data.friendIds || []);
|
||||||
div.innerHTML = `<strong>${msg.username}:</strong> ${msg.content}`;
|
|
||||||
this.output.appendChild(div);
|
// Display recent messages
|
||||||
this.output.scrollTop = this.output.scrollHeight;
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,30 +5,22 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>scribl.lidl_edition</title>
|
<title>scribl.lidl_edition</title>
|
||||||
<link rel="stylesheet" href="style.css" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>scribl.lidl_edition</h1>
|
<h1 class="title">scribl.lidl_edition</h1>
|
||||||
|
|
||||||
<!-- Main menu -->
|
<nav class="menu" aria-label="Menu principal">
|
||||||
<nav id="menu" aria-label="Menu principal">
|
<button class="menu__item" data-action="login" aria-label="Login">Login</button>
|
||||||
<button id="accueil" aria-label="Accueil">Accueil</button>
|
<button class="menu__item" data-action="chat" aria-label="Global chat">Global chat</button>
|
||||||
<button id="explorer" aria-label="Explorer">Explorer</button>
|
<button class="menu__item" data-action="avatar" aria-label="Avatar">Avatar</button>
|
||||||
<button id="registered" aria-label="Enregistré">Enregistré</button>
|
<button class="menu__item" data-action="friends" aria-label="Amis">Amis</button>
|
||||||
<button id="login" aria-label="Login">Login</button>
|
|
||||||
<button id="global_chat" aria-label="Global chat">Global chat</button>
|
|
||||||
<button id="avatar" aria-label="avatar">avatar</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<button onclick="direBonjour()" style="position: absolute; top: 20%; left: 50%; z-index: 1;">
|
<button class="easter-egg" data-action="easter-egg">Ne cliquez pas !</button>
|
||||||
Ne cliquez pas !
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<script type="module" src="app.js"></script>
|
<script type="module" src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
</script>
|
|
||||||
|
|
||||||
</html></script>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|||||||
+214
-151
@@ -1,172 +1,235 @@
|
|||||||
import {fenetre} from "./windows.js";
|
import { Window } from './windows.js';
|
||||||
import {avatarWindow} from "./app.js";
|
import { API, STORAGE_KEYS, CSS } from './config.js';
|
||||||
export class LoginWindow extends fenetre {
|
import { eventBus, Events } from './events.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login and registration window
|
||||||
|
* Emits events instead of directly importing other windows
|
||||||
|
*/
|
||||||
|
export class LoginWindow extends Window {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(320, 240, "Connexion");
|
super({
|
||||||
|
name: 'login',
|
||||||
|
title: 'Login',
|
||||||
|
cssClasses: ['login']
|
||||||
|
});
|
||||||
|
|
||||||
this.mode = "login";
|
this.buildUI();
|
||||||
|
this.bindEvents();
|
||||||
|
this.checkIfAlreadyLoggedIn();
|
||||||
|
}
|
||||||
|
|
||||||
this.username = document.createElement("input");
|
/**
|
||||||
this.username.placeholder = "Username";
|
* Builds the user interface
|
||||||
|
*/
|
||||||
|
buildUI() {
|
||||||
|
// Main form
|
||||||
|
this.form = this.createElement('div', 'login__form');
|
||||||
|
|
||||||
this.password = document.createElement("input");
|
// Username field
|
||||||
this.password.type = "password";
|
this.usernameInput = this.createElement('input', CSS.INPUT, {
|
||||||
this.password.placeholder = "Password";
|
type: 'text',
|
||||||
|
placeholder: 'Username'
|
||||||
|
});
|
||||||
|
|
||||||
this.submit = document.createElement("button");
|
// Password field
|
||||||
this.submit.innerText = "Se connecter";
|
this.passwordInput = this.createElement('input', CSS.INPUT, {
|
||||||
|
type: 'password',
|
||||||
|
placeholder: 'Password'
|
||||||
|
});
|
||||||
|
|
||||||
this.switch = document.createElement("button");
|
// Action buttons
|
||||||
this.switch.innerText = "S'inscrire";
|
this.actions = this.createElement('div', 'login__actions');
|
||||||
|
|
||||||
this.message = document.createElement("div");
|
this.loginBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
|
||||||
this.message.style.fontSize = "0.8em";
|
text: 'Sign in'
|
||||||
|
});
|
||||||
|
|
||||||
this.body.append(
|
this.registerBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
|
||||||
this.username,
|
text: 'Register'
|
||||||
this.password,
|
});
|
||||||
this.submit,
|
|
||||||
this.switch,
|
this.actions.append(this.loginBtn, this.registerBtn);
|
||||||
this.message
|
|
||||||
|
// 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.applyStyles();
|
this.body.appendChild(this.form);
|
||||||
this.bindEvents();
|
|
||||||
|
|
||||||
// **** ADDED GITHUB FUNCTION ****
|
|
||||||
// In constructor() of LoginWindow
|
|
||||||
this.githubBtn = document.createElement("button");
|
|
||||||
this.githubBtn.innerText = "Se connecter avec GitHub";
|
|
||||||
this.githubBtn.style.backgroundColor = "#24292e";
|
|
||||||
this.githubBtn.style.color = "white";
|
|
||||||
this.githubBtn.onclick = () => {
|
|
||||||
// Open the OAUTH Github in a popup and receive the token from postMessage
|
|
||||||
const w = 600;
|
|
||||||
const h = 700;
|
|
||||||
const left = (screen.width - w) / 2;
|
|
||||||
const top = (screen.height - h) / 2;
|
|
||||||
const popup = window.open('/api/auth/github', 'githubOAuth', `width=${w},height=${h},left=${left},top=${top}`);
|
|
||||||
const listener = (ev) => {
|
|
||||||
if (ev.data && ev.data.token) {
|
|
||||||
localStorage.setItem('auth_token', ev.data.token);
|
|
||||||
this.message.innerText = 'Connexion GitHub réussie ! Bienvenue.';
|
|
||||||
avatarWindow.getPhoto();
|
|
||||||
this.message.style.color = '#3cff01';
|
|
||||||
window.removeEventListener('message', listener);
|
|
||||||
if (popup) popup.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('message', listener, {once: true});
|
|
||||||
};
|
|
||||||
this.body.appendChild(this.githubBtn);
|
|
||||||
|
|
||||||
this.checkIfAlreadyLoggedIn(); // Verify if the user is connected on startup
|
|
||||||
}
|
|
||||||
|
|
||||||
applyStyles() {
|
|
||||||
this.body.style.display = "flex";
|
|
||||||
this.body.style.flexDirection = "column";
|
|
||||||
this.body.style.gap = "8px";
|
|
||||||
}
|
|
||||||
|
|
||||||
checkIfAlreadyLoggedIn(){
|
|
||||||
const token = localStorage.getItem("auth_token");
|
|
||||||
if (token) {
|
|
||||||
this.message.innerText = "Vous êtes déjà connecté !";
|
|
||||||
this.message.style.color = "#3cff01";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async connexion() {
|
|
||||||
console.log("methode connexion lancée");
|
|
||||||
const response = await fetch("/api/auth/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: this.username.value,
|
|
||||||
password: this.password.value
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
console.log("connexion ok", data);
|
|
||||||
// *** TOKEN STORAGE ***
|
|
||||||
if (data.token) {
|
|
||||||
localStorage.setItem("auth_token", data.token);
|
|
||||||
this.message.innerText = "Connexion réussie ! Bienvenue.";
|
|
||||||
this.message.style.color = "#3cff01";
|
|
||||||
avatarWindow.getPhoto();
|
|
||||||
// mask the window after 1.5s
|
|
||||||
setTimeout(() => this.hide(), 1500);
|
|
||||||
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.message.innerText = "Token manquant dans la réponse";
|
|
||||||
this.message.style.color = "#ff4444";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Show a more visible user error
|
|
||||||
const errMsg = data && data.message ? data.message : "Échec de la connexion";
|
|
||||||
this.message.innerText = errMsg;
|
|
||||||
this.message.style.color = "#ff4d4d";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async inscription(){
|
|
||||||
console.log("methode inscription lancée");
|
|
||||||
const response = await fetch("/api/auth/register", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: this.username.value,
|
|
||||||
password: this.password.value
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
console.log("OK", data);
|
|
||||||
} else {
|
|
||||||
console.error("ERROR", data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches event handlers
|
||||||
|
*/
|
||||||
bindEvents() {
|
bindEvents() {
|
||||||
this.switch.onclick = () => this.toggleMode();
|
this.loginBtn.addEventListener('click', () => this.handleLogin());
|
||||||
|
this.registerBtn.addEventListener('click', () => this.handleRegister());
|
||||||
|
this.githubBtn.addEventListener('click', () => this.handleGitHubLogin());
|
||||||
|
|
||||||
this.submit.onclick = () => {
|
// Login with Enter
|
||||||
this.message.innerText = this.mode === "login"
|
this.passwordInput.addEventListener('keypress', (e) => {
|
||||||
? "Tentative de connexion..."
|
if (e.key === 'Enter') {
|
||||||
: "Tentative d'inscription...";
|
this.handleLogin();
|
||||||
if (this.mode === "login"){
|
|
||||||
this.connexion();
|
|
||||||
}
|
}
|
||||||
else {
|
});
|
||||||
this.inscription();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMode() {
|
/**
|
||||||
if (this.mode === "login") {
|
* Checks if user is already logged in
|
||||||
this.mode = "register";
|
*/
|
||||||
this.header.firstChild.textContent = "Inscription";
|
checkIfAlreadyLoggedIn() {
|
||||||
this.submit.innerText = "S'inscrire";
|
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||||
this.switch.innerText = "Se connecter";
|
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 {
|
} else {
|
||||||
this.mode = "login";
|
this.message.classList.add(CSS.MESSAGE_INFO);
|
||||||
this.header.firstChild.textContent = "Connexion";
|
|
||||||
this.submit.innerText = "Se connecter";
|
|
||||||
this.switch.innerText = "S'inscrire";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+594
-41
@@ -1,58 +1,611 @@
|
|||||||
/* || General setup */
|
/* ============================================
|
||||||
|
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 {
|
html {
|
||||||
font-size: 10px;
|
font-size: var(--font-size-base);
|
||||||
background-color: rgb(0, 0, 0);
|
background-color: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
width: 70%;
|
width: 70%;
|
||||||
min-width: 800px;
|
min-width: 800px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* || typography */
|
/* ============================================
|
||||||
|
TYPOGRAPHY
|
||||||
h1 {
|
============================================ */
|
||||||
position: absolute;
|
.title {
|
||||||
top: 0%;
|
position: absolute;
|
||||||
left: 50%;
|
top: 0;
|
||||||
text-transform: uppercase;
|
left: 50%;
|
||||||
display: flex;
|
transform: translateX(-50%);
|
||||||
align-items: center;
|
text-transform: uppercase;
|
||||||
justify-content: center;
|
display: flex;
|
||||||
gap: 20px;
|
align-items: center;
|
||||||
font-size: 4rem;
|
justify-content: center;
|
||||||
text-align: center;
|
gap: 20px;
|
||||||
text-shadow: 2px 2px 10px black;
|
font-size: var(--font-size-xl);
|
||||||
z-index: 1;
|
text-align: center;
|
||||||
font-family: "Cinzel Decorative", cursive;
|
text-shadow: 2px 2px 10px black;
|
||||||
color: #3cff01;
|
z-index: 1;
|
||||||
|
font-family: "Cinzel Decorative", cursive;
|
||||||
|
color: var(--color-success);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* id */
|
/* ============================================
|
||||||
#menu{
|
MENU
|
||||||
position: fixed;
|
============================================ */
|
||||||
top: 0px;
|
.menu {
|
||||||
left: 50px;
|
position: fixed;
|
||||||
padding-inline-start: 0px;
|
top: 0;
|
||||||
z-index: 2;
|
left: 50px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
z-index: var(--z-menu);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu li {
|
.menu__item {
|
||||||
list-style-type: none;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
#loginWindow {
|
.menu__item:hover {
|
||||||
position: fixed;
|
background: var(--color-surface-light);
|
||||||
z-index: 3;
|
font-size: var(--font-size-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Message styling for system/info messages in windows */
|
.menu__item--active {
|
||||||
.system {
|
background: var(--color-primary);
|
||||||
color: #333;
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.system.error {
|
|
||||||
color: #ff4d4d;
|
/* ============================================
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+222
-70
@@ -1,82 +1,234 @@
|
|||||||
export class fenetre {
|
import { CSS } from './config.js';
|
||||||
constructor(width = 320, height = 220, title = "Window") {
|
import { eventBus, Events } from './events.js';
|
||||||
this.main = document.createElement("div");
|
|
||||||
this.main.style.width = width + "px";
|
|
||||||
this.main.style.height = height + "px";
|
|
||||||
this.main.style.position = "fixed";
|
|
||||||
this.main.style.top = "50%";
|
|
||||||
this.main.style.left = "50%";
|
|
||||||
this.main.style.transform = "translate(-50%, -50%)";
|
|
||||||
this.main.style.backgroundColor = "#000";
|
|
||||||
this.main.style.border = "2px ridge white";
|
|
||||||
this.main.style.color = "white";
|
|
||||||
this.main.style.zIndex = "100";
|
|
||||||
this.main.style.display = "none";
|
|
||||||
// Mark windows for layout management (side-by-side when multiple open)
|
|
||||||
this.main.classList.add("trans-window");
|
|
||||||
|
|
||||||
// Header
|
/**
|
||||||
this.header = document.createElement("div");
|
* Centralized window registry
|
||||||
this.header.innerText = title;
|
* Manages window visibility and positioning
|
||||||
this.header.style.padding = "6px";
|
*/
|
||||||
this.header.style.background = "#222";
|
class WindowRegistry {
|
||||||
this.header.style.cursor = "move";
|
constructor() {
|
||||||
|
this.windows = new Map();
|
||||||
// Close
|
|
||||||
this.closeBtn = document.createElement("span");
|
|
||||||
this.closeBtn.innerText = "✖";
|
|
||||||
this.closeBtn.style.float = "right";
|
|
||||||
this.closeBtn.style.cursor = "pointer";
|
|
||||||
this.closeBtn.onclick = () => this.hide();
|
|
||||||
|
|
||||||
this.header.appendChild(this.closeBtn);
|
|
||||||
|
|
||||||
// Body
|
|
||||||
this.body = document.createElement("div");
|
|
||||||
this.body.style.padding = "10px";
|
|
||||||
|
|
||||||
this.main.append(this.header, this.body);
|
|
||||||
document.body.appendChild(this.main);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
show() {
|
/**
|
||||||
// If no other windows are open, center this window
|
* Registers a window in the registry
|
||||||
const openWindows = Array.from(document.querySelectorAll(".trans-window"))
|
* @param {string} name - Unique window name
|
||||||
.filter(el => el.style.display !== "none");
|
* @param {Window} window - Window instance
|
||||||
|
*/
|
||||||
|
register(name, window) {
|
||||||
|
this.windows.set(name, window);
|
||||||
|
}
|
||||||
|
|
||||||
if (openWindows.length === 0) {
|
/**
|
||||||
this.main.style.left = "50%";
|
* Gets a window by its name
|
||||||
this.main.style.top = "50%";
|
* @param {string} name - Window name
|
||||||
this.main.style.transform = "translate(-50%, -50%)";
|
* @returns {Window|undefined}
|
||||||
} else if (openWindows.length === 1) {
|
*/
|
||||||
// Layout two windows side-by-side: left and right
|
get(name) {
|
||||||
const other = openWindows[0];
|
return this.windows.get(name);
|
||||||
other.style.left = "15%";
|
}
|
||||||
other.style.top = "50%";
|
|
||||||
other.style.transform = "translate(-50%, -50%)";
|
|
||||||
|
|
||||||
this.main.style.left = "65%";
|
/**
|
||||||
this.main.style.top = "50%";
|
* Returns all visible windows
|
||||||
this.main.style.transform = "translate(-50%, -50%)";
|
* @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 {
|
} else {
|
||||||
// Fallback: center if more than two windows are open
|
visible.forEach(w => w.setPosition('center'));
|
||||||
this.main.style.left = "50%";
|
|
||||||
this.main.style.top = "50%";
|
|
||||||
this.main.style.transform = "translate(-50%, -50%)";
|
|
||||||
}
|
}
|
||||||
this.main.style.display = "block";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
/**
|
||||||
this.main.style.display = "none";
|
* Shows a window and reorganizes
|
||||||
// If only one window remains visible, center it
|
* @param {string} name - Window name
|
||||||
const visibles = Array.from(document.querySelectorAll(".trans-window"))
|
*/
|
||||||
.filter(el => el.style.display !== "none");
|
show(name) {
|
||||||
if (visibles.length === 1) {
|
const window = this.get(name);
|
||||||
const w = visibles[0];
|
if (window) {
|
||||||
w.style.left = "50%";
|
window.show();
|
||||||
w.style.top = "50%";
|
}
|
||||||
w.style.transform = "translate(-50%, -50%)";
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user