diff --git a/projects/gp-2/index.html b/projects/gp-2/index.html
new file mode 100644
index 0000000..48bef79
--- /dev/null
+++ b/projects/gp-2/index.html
@@ -0,0 +1,183 @@
+
+
diff --git a/projects/gp-2/index.js b/projects/gp-2/index.js
new file mode 100644
index 0000000..6aed62f
--- /dev/null
+++ b/projects/gp-2/index.js
@@ -0,0 +1,4 @@
+import './index.html';
+import MegaChat from './megaChat';
+
+new MegaChat();
diff --git a/projects/gp-2/megaChat.js b/projects/gp-2/megaChat.js
new file mode 100644
index 0000000..f872e6b
--- /dev/null
+++ b/projects/gp-2/megaChat.js
@@ -0,0 +1,92 @@
+import LoginWindow from './ui/loginWindow';
+import MainWindow from './ui/mainWindow';
+import UserName from './ui/userName';
+import UserList from './ui/userList';
+import UserPhoto from './ui/userPhoto';
+import MessageList from './ui/messageList';
+import MessageSender from './ui/messageSender';
+import WSClient from './wsClient';
+
+export default class MegaChat {
+ constructor() {
+ this.wsClient = new WSClient(
+ `ws://${location.host}/mega-chat-3/ws`,
+ this.onMessage.bind(this)
+ );
+
+ this.ui = {
+ loginWindow: new LoginWindow(
+ document.querySelector('#login'),
+ this.onLogin.bind(this)
+ ),
+ mainWindow: new MainWindow(document.querySelector('#main')),
+ userName: new UserName(document.querySelector('[data-role=user-name]')),
+ userList: new UserList(document.querySelector('[data-role=user-list]')),
+ messageList: new MessageList(document.querySelector('[data-role=messages-list]')),
+ messageSender: new MessageSender(
+ document.querySelector('[data-role=message-sender]'),
+ this.onSend.bind(this)
+ ),
+ userPhoto: new UserPhoto(
+ document.querySelector('[data-role=user-photo]'),
+ this.onUpload.bind(this)
+ ),
+ };
+
+ this.ui.loginWindow.show();
+ }
+
+ onUpload(data) {
+ this.ui.userPhoto.set(data);
+
+ fetch('/mega-chat-3/upload-photo', {
+ method: 'post',
+ body: JSON.stringify({
+ name: this.ui.userName.get(),
+ image: data,
+ }),
+ });
+ }
+
+ onSend(message) {
+ this.wsClient.sendTextMessage(message);
+ this.ui.messageSender.clear();
+ }
+
+ async onLogin(name) {
+ await this.wsClient.connect();
+ this.wsClient.sendHello(name);
+ this.ui.loginWindow.hide();
+ this.ui.mainWindow.show();
+ this.ui.userName.set(name);
+ this.ui.userPhoto.set(`/mega-chat-3/photos/${name}.png?t=${Date.now()}`);
+ }
+
+ onMessage({ type, from, data }) {
+ console.log(type, from, data);
+
+ if (type === 'hello') {
+ this.ui.userList.add(from);
+ this.ui.messageList.addSystemMessage(`${from} вошел в чат`);
+ } else if (type === 'user-list') {
+ for (const item of data) {
+ this.ui.userList.add(item);
+ }
+ } else if (type === 'bye-bye') {
+ this.ui.userList.remove(from);
+ this.ui.messageList.addSystemMessage(`${from} вышел из чата`);
+ } else if (type === 'text-message') {
+ this.ui.messageList.add(from, data.message);
+ } else if (type === 'photo-changed') {
+ const avatars = document.querySelectorAll(
+ `[data-role=user-avatar][data-user=${data.name}]`
+ );
+
+ for (const avatar of avatars) {
+ avatar.style.backgroundImage = `url(/mega-chat-3/photos/${
+ data.name
+ }.png?t=${Date.now()})`;
+ }
+ }
+ }
+}
diff --git a/projects/gp-2/no-photo.png b/projects/gp-2/no-photo.png
new file mode 100644
index 0000000..4de590b
Binary files /dev/null and b/projects/gp-2/no-photo.png differ
diff --git a/projects/gp-2/photos/.gitkeep b/projects/gp-2/photos/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/projects/gp-2/server/index.js b/projects/gp-2/server/index.js
new file mode 100644
index 0000000..c68d421
--- /dev/null
+++ b/projects/gp-2/server/index.js
@@ -0,0 +1,110 @@
+// не забудьте сделать npm install ;)
+
+const fs = require('fs');
+const path = require('path');
+const http = require('http');
+const { Server } = require('ws');
+
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ let dataRaw = '';
+
+ req.on('data', (chunk) => (dataRaw += chunk));
+ req.on('error', reject);
+ req.on('end', () => resolve(JSON.parse(dataRaw)));
+ });
+}
+
+const server = http.createServer(async (req, res) => {
+ try {
+ if (/\/photos\/.+\.png/.test(req.url)) {
+ const [, imageName] = req.url.match(/\/photos\/(.+\.png)/) || [];
+ const fallBackPath = path.resolve(__dirname, '../no-photo.png');
+ const filePath = path.resolve(__dirname, '../photos', imageName);
+
+ if (fs.existsSync(filePath)) {
+ return fs.createReadStream(filePath).pipe(res);
+ } else {
+ return fs.createReadStream(fallBackPath).pipe(res);
+ }
+ } else if (req.url.endsWith('/upload-photo')) {
+ const body = await readBody(req);
+ const name = body.name.replace(/\.\.\/|\//, '');
+ const [, content] = body.image.match(/data:image\/.+?;base64,(.+)/) || [];
+ const filePath = path.resolve(__dirname, '../photos', `${name}.png`);
+
+ if (name && content) {
+ fs.writeFileSync(filePath, content, 'base64');
+
+ broadcast(connections, { type: 'photo-changed', data: { name } });
+ } else {
+ return res.end('fail');
+ }
+ }
+
+ res.end('ok');
+ } catch (e) {
+ console.error(e);
+ res.end('fail');
+ }
+});
+const wss = new Server({ server });
+const connections = new Map();
+
+wss.on('connection', (socket) => {
+ connections.set(socket, {});
+
+ socket.on('message', (messageData) => {
+ const message = JSON.parse(messageData);
+ let excludeItself = false;
+
+ if (message.type === 'hello') {
+ excludeItself = true;
+ connections.get(socket).userName = message.data.name;
+ sendMessageTo(
+ {
+ type: 'user-list',
+ data: [...connections.values()].map((item) => item.userName).filter(Boolean),
+ },
+ socket
+ );
+ }
+
+ sendMessageFrom(connections, message, socket, excludeItself);
+ });
+
+ socket.on('close', () => {
+ sendMessageFrom(connections, { type: 'bye-bye' }, socket);
+ connections.delete(socket);
+ });
+});
+
+function sendMessageTo(message, to) {
+ to.send(JSON.stringify(message));
+}
+
+function broadcast(connections, message) {
+ for (const connection of connections.keys()) {
+ connection.send(JSON.stringify(message));
+ }
+}
+
+function sendMessageFrom(connections, message, from, excludeSelf) {
+ const socketData = connections.get(from);
+
+ if (!socketData) {
+ return;
+ }
+
+ message.from = socketData.userName;
+
+ for (const connection of connections.keys()) {
+ if (connection === from && excludeSelf) {
+ continue;
+ }
+
+ connection.send(JSON.stringify(message));
+ }
+}
+
+server.listen(8282);
diff --git a/projects/gp-2/server/package.json b/projects/gp-2/server/package.json
new file mode 100644
index 0000000..5a3d4ba
--- /dev/null
+++ b/projects/gp-2/server/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "server",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "author": "",
+ "license": "ISC",
+ "dependencies": {
+ "ws": "^7.3.1"
+ }
+}
diff --git a/projects/gp-2/settings.json b/projects/gp-2/settings.json
new file mode 100644
index 0000000..fd1cbfe
--- /dev/null
+++ b/projects/gp-2/settings.json
@@ -0,0 +1,14 @@
+{
+ "proxy": {
+ "/mega-chat-3/ws": {
+ "target": "ws://localhost:8282",
+ "ws": true
+ },
+ "/mega-chat-3/upload-photo": {
+ "target": "http://localhost:8282"
+ },
+ "/mega-chat-3/photos": {
+ "target": "http://localhost:8282"
+ }
+ }
+}
diff --git a/projects/gp-2/ui/loginWindow.js b/projects/gp-2/ui/loginWindow.js
new file mode 100644
index 0000000..89727d3
--- /dev/null
+++ b/projects/gp-2/ui/loginWindow.js
@@ -0,0 +1,30 @@
+export default class LoginWindow {
+ constructor(element, onLogin) {
+ this.element = element;
+ this.onLogin = onLogin;
+
+ const loginNameInput = element.querySelector('[data-role=login-name-input]');
+ const submitButton = element.querySelector('[data-role=login-submit]');
+ const loginError = element.querySelector('[data-role=login-error]');
+
+ submitButton.addEventListener('click', () => {
+ loginError.textContent = '';
+
+ const name = loginNameInput.value.trim();
+
+ if (!name) {
+ loginError.textContent = 'Введите никнейм';
+ } else {
+ this.onLogin(name);
+ }
+ });
+ }
+
+ show() {
+ this.element.classList.remove('hidden');
+ }
+
+ hide() {
+ this.element.classList.add('hidden');
+ }
+}
diff --git a/projects/gp-2/ui/mainWindow.js b/projects/gp-2/ui/mainWindow.js
new file mode 100644
index 0000000..2b319ef
--- /dev/null
+++ b/projects/gp-2/ui/mainWindow.js
@@ -0,0 +1,13 @@
+export default class MainWindow {
+ constructor(element) {
+ this.element = element;
+ }
+
+ show() {
+ this.element.classList.remove('hidden');
+ }
+
+ hide() {
+ this.element.classList.add('hidden');
+ }
+}
diff --git a/projects/gp-2/ui/messageList.js b/projects/gp-2/ui/messageList.js
new file mode 100644
index 0000000..5d38b6a
--- /dev/null
+++ b/projects/gp-2/ui/messageList.js
@@ -0,0 +1,46 @@
+import { sanitize } from '../utils';
+
+export default class MessageList {
+ constructor(element) {
+ this.element = element;
+ }
+
+ add(from, text) {
+ const date = new Date();
+ const hours = String(date.getHours()).padStart(2, 0);
+ const minutes = String(date.getMinutes()).padStart(2, 0);
+ const time = `${hours}:${minutes}`;
+ const item = document.createElement('div');
+
+ item.classList.add('message-item');
+ item.innerHTML = `
+
+
+ `;
+
+ this.element.append(item);
+ this.element.scrollTop = this.element.scrollHeight;
+ }
+
+ addSystemMessage(message) {
+ const item = document.createElement('div');
+
+ item.classList.add('message-item', 'message-item-system');
+ item.textContent = message;
+
+ this.element.append(item);
+ this.element.scrollTop = this.element.scrollHeight;
+ }
+}
diff --git a/projects/gp-2/ui/messageSender.js b/projects/gp-2/ui/messageSender.js
new file mode 100644
index 0000000..f179b3b
--- /dev/null
+++ b/projects/gp-2/ui/messageSender.js
@@ -0,0 +1,19 @@
+export default class MessageSender {
+ constructor(element, onSend) {
+ this.onSend = onSend;
+ this.messageInput = element.querySelector('[data-role=message-input]');
+ this.messageSendButton = element.querySelector('[data-role=message-send-button]');
+
+ this.messageSendButton.addEventListener('click', () => {
+ const message = this.messageInput.value.trim();
+
+ if (message) {
+ this.onSend(message);
+ }
+ });
+ }
+
+ clear() {
+ this.messageInput.value = '';
+ }
+}
diff --git a/projects/gp-2/ui/userList.js b/projects/gp-2/ui/userList.js
new file mode 100644
index 0000000..d86e601
--- /dev/null
+++ b/projects/gp-2/ui/userList.js
@@ -0,0 +1,31 @@
+export default class UserList {
+ constructor(element) {
+ this.element = element;
+ this.items = new Set();
+ }
+
+ buildDOM() {
+ const fragment = document.createDocumentFragment();
+
+ this.element.innerHTML = '';
+
+ for (const name of this.items) {
+ const element = document.createElement('div');
+ element.classList.add('user-list-item');
+ element.textContent = name;
+ fragment.append(element);
+ }
+
+ this.element.append(fragment);
+ }
+
+ add(name) {
+ this.items.add(name);
+ this.buildDOM();
+ }
+
+ remove(name) {
+ this.items.delete(name);
+ this.buildDOM();
+ }
+}
diff --git a/projects/gp-2/ui/userName.js b/projects/gp-2/ui/userName.js
new file mode 100644
index 0000000..fe09719
--- /dev/null
+++ b/projects/gp-2/ui/userName.js
@@ -0,0 +1,14 @@
+export default class UserName {
+ constructor(element) {
+ this.element = element;
+ }
+
+ set(name) {
+ this.name = name;
+ this.element.textContent = name;
+ }
+
+ get() {
+ return this.name;
+ }
+}
diff --git a/projects/gp-2/ui/userPhoto.js b/projects/gp-2/ui/userPhoto.js
new file mode 100644
index 0000000..09278c0
--- /dev/null
+++ b/projects/gp-2/ui/userPhoto.js
@@ -0,0 +1,25 @@
+export default class UserPhoto {
+ constructor(element, onUpload) {
+ this.element = element;
+ this.onUpload = onUpload;
+
+ this.element.addEventListener('dragover', (e) => {
+ if (e.dataTransfer.items.length && e.dataTransfer.items[0].kind === 'file') {
+ e.preventDefault();
+ }
+ });
+
+ this.element.addEventListener('drop', (e) => {
+ const file = e.dataTransfer.items[0].getAsFile();
+ const reader = new FileReader();
+
+ reader.readAsDataURL(file);
+ reader.addEventListener('load', () => this.onUpload(reader.result));
+ e.preventDefault();
+ });
+ }
+
+ set(photo) {
+ this.element.style.backgroundImage = `url(${photo})`;
+ }
+}
diff --git a/projects/gp-2/utils.js b/projects/gp-2/utils.js
new file mode 100644
index 0000000..658c232
--- /dev/null
+++ b/projects/gp-2/utils.js
@@ -0,0 +1,16 @@
+const UNSAFE_CHARS_RE = /<|>\/|'|\u2028|\u2029/g;
+
+const ESCAPED_CHARS = {
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '\\u0027',
+ '': '<\\u002F',
+ '\u2028': '\\u2028',
+ '\u2029': '\\u2029',
+};
+const escapeUnsafeChars = (unsafeChar) => ESCAPED_CHARS[unsafeChar];
+
+export function sanitize(string) {
+ return string.replace(UNSAFE_CHARS_RE, escapeUnsafeChars);
+}
diff --git a/projects/gp-2/wsClient.js b/projects/gp-2/wsClient.js
new file mode 100644
index 0000000..039106a
--- /dev/null
+++ b/projects/gp-2/wsClient.js
@@ -0,0 +1,33 @@
+export default class WSClient {
+ constructor(url, onMessage) {
+ this.url = url;
+ this.onMessage = onMessage;
+ }
+
+ connect() {
+ return new Promise((resolve) => {
+ this.socket = new WebSocket(this.url);
+ this.socket.addEventListener('open', resolve);
+ this.socket.addEventListener('message', (e) => {
+ this.onMessage(JSON.parse(e.data));
+ });
+ });
+ }
+
+ sendHello(name) {
+ this.sendMessage('hello', { name });
+ }
+
+ sendTextMessage(message) {
+ this.sendMessage('text-message', { message });
+ }
+
+ sendMessage(type, data) {
+ this.socket.send(
+ JSON.stringify({
+ type,
+ data,
+ })
+ );
+ }
+}