diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..76a93c065
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,3 @@
+[*]
+indent_style = space
+indent_size = 2
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
new file mode 100644
index 000000000..da994d3a1
--- /dev/null
+++ b/.eslintrc.cjs
@@ -0,0 +1,34 @@
+module.exports = {
+ "env": {
+ "browser": true,
+ "node": true,
+ "es2021": true
+ },
+ "extends": [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended"
+ ],
+ "overrides": [
+ {
+ "env": {
+ "node": true
+ },
+ "files": [
+ ".eslintrc.{js,cjs}"
+ ],
+ "parserOptions": {
+ "sourceType": "script"
+ }
+ }
+ ],
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "ecmaVersion": "latest",
+ "sourceType": "module"
+ },
+ "plugins": [
+ "@typescript-eslint"
+ ],
+ "rules": {
+ },
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..914172752
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,26 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+.stylelintcache
+package-lock.json
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 000000000..fa51da29e
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,6 @@
+{
+ "trailingComma": "es5",
+ "tabWidth": 2,
+ "semi": false,
+ "singleQuote": true
+}
diff --git a/.stylelintrc b/.stylelintrc
new file mode 100644
index 000000000..f4cff1201
--- /dev/null
+++ b/.stylelintrc
@@ -0,0 +1,5 @@
+{
+ "extends": "stylelint-config-standard",
+ "rules": {"selector-class-pattern": null
+ }
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 000000000..9d32eb3a6
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Yan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index bcd1a1367..cda7367ef 100644
--- a/README.md
+++ b/README.md
@@ -1,83 +1,42 @@
-### Ветка, в которой делаете задания спринта, должна называться sprint_i, где i - номер спринта. Не переименовывайте её.
+# Тренировочный проект мессенджера.
-### Откройте pull request в ветку main из ветки, где вы разрабатывали проект, и добавьте ссылку на этот pr в README.md в ветке main.
-### ВАЖНО: pull request должен называться “Sprint i” (i — номер спринта).
+Использовался сборщик Vite, Typescript, линтеры Eslint и Stylelint, шаблонизатор Handlebars
-### Например, задания для проектной работы во втором спринте вы делаете в ветке sprint_2. Открываете из неё pull request в ветку main. Ссылку на этот pr добавляете в README.md в ветке main. После этого на платформе Практикума нажимаете «Проверить задание».
+### Макеты приложения
-### Также не забудьте проверить, что репозиторий публичный.
----
+[Figma](https://www.figma.com/file/EhwzOuHmvUtGVE62At4ZgK/Chat_external_link-(Copy)?type=design&mode=design&t=bhHRbfH2S9ivRlpz-1 "Макеты в Figma")
+### Ссылки на страницы приложения при запуске локально
-Даже законченный проект остаётся только заготовкой, пока им не начнут пользоваться. Но сначала пользователь должен понять, зачем ему пользоваться вашим кодом. В этом помогает файл README.
+| Экран | Ссылка |
+|:----------------|-------------------------------------|
+| Логин | http://localhost:3000/#login |
+| Регистрация | http://localhost:3000/#register |
+| Мессенджер | http://localhost:3000/#main |
+| Профиль | http://localhost:3000/#profile |
+| Изменить данные | http://localhost:3000/#editUserdata |
+| Изменить пароль | http://localhost:3000/#editPassword |
+| 404 | http://localhost:3000/#notFound |
+| 500 | http://localhost:3000/#serverError |
-README — первое, что прочитает пользователь, когда попадёт в репозиторий на «Гитхабе». Хороший REAMDE отвечает на четыре вопроса:
+### Ссылки на страницы приложения на Netlify
-- Готов ли проект к использованию?
-- В чём его польза?
-- Как установить?
-- Как применять?
+| Экран | Ссылка |
+|:----------------|------------------------------------------|
+| Логин | https://yamess.netlify.app/#login |
+| Регистрация | https://yamess.netlify.app/#register |
+| Мессенджер | https://yamess.netlify.app/#main |
+| Профиль | https://yamess.netlify.app/#profile |
+| Изменить данные | https://yamess.netlify.app/#editUserdata |
+| Изменить пароль | https://yamess.netlify.app/#editPassword |
+| 404 | https://yamess.netlify.app/#notFound |
+| 500 | https://yamess.netlify.app/#serverError |
-## Бейджи
+### Команды npm
-Быстро понять статус проекта помогают бейджи на «Гитхабе». Иногда разработчики ограничиваются парой бейджев, которые сообщат о статусе тестов кода:
+| Команда | Результат |
+|:--------------|-----------------------------------|
+| npm run start | сборка и запуск сервера Express |
+| npm run dev | запуск dev сервера для разработки |
+| npm run build | запуск сборки проекта |
-![Бэйджи](https://github.com/yandex-praktikum/mf.messenger.praktikum.yandex.images/blob/master/mf/b.png)
-
-Если пользователь увидит ошибку в работе тестов, то поймёт: использовать текущую версию в важном проекте — не лучшая идея.
-
-Бейджи помогают похвастаться достижениями: насколько популярен проект, как много разработчиков создавало этот код. Через бейджи можно даже пригласить пользователя в чат:
-
-![Версии](https://github.com/yandex-praktikum/mf.messenger.praktikum.yandex.images/blob/master/mf/vers.png)
-
-В README **Webpack** строка бейджев подробно рассказывает о покрытии кода тестами. Когда проект протестирован, это вызывает доверие пользователя. Последний бейдж приглашает присоединиться к разработке.
-
-Другая строка убедит пользователя в стабильности инфраструктуры и популярности проекта. Последний бейдж зовёт в чат проекта.
-
-## Описание
-
-Краткое опишите, какую задачу решает проект. Пользователь не верит обещаниям и не готов читать «полотна» текста. Поэтому в описании достаточно нескольких строк:
-
-![Описание](https://github.com/yandex-praktikum/mf.messenger.praktikum.yandex.images/blob/master/mf/desc.png)
-
-Авторы **React** дробят описание на абзацы и списки — так проще пробежаться глазами по тексту и найти ключевую информацию.
-
-Если у проекта есть сайт, добавьте ссылку в заголовок.
-
-## Установка
-
-Лучше всего пользователя убеждает собственный опыт. Чем быстрее он начнёт пользоваться проектом, тем раньше почувствует пользу. Для этого помогите ему установить приложение: напишите краткую пошаговую инструкцию.
-
-Если проект предназначен для разработчиков, добавьте информацию об установке тестовых версий. Например:
-
-- `npm install` — установка стабильной версии,
-- `npm start` — запуск версии для разработчика,
-- `npm run build:prod` — сборка стабильной версии.
-
-## **Примеры использования**
-
-Хорошо, если сразу после установки пользователь сможет решить свои задачи без изучения проекта. Это особенно верно, если ваш пользователь — не профессиональный разработчик. Но даже профессионал поймёт вас лучше, если показать примеры использования:
-
-![Ссылки](https://github.com/yandex-praktikum/mf.messenger.praktikum.yandex.images/blob/master/mf/link.png)
-
-Для более подробных инструкции добавьте новые разделы или ссылки:
-
-- на документацию,
-- вики проекта,
-- описание API.
-
-В учебном проекте будут полезен раздел с описанием стиля кода и правилами разработки: как работать с ветками, пул-реквестами и релизами.
-
-### **Команда**
-
-Если вы работаете в команде, укажите основных участников: им будет приятно, а новые разработчики охотнее присоединятся к проекту. «Гитхаб» — не просто инструмент, это социальная сеть разработчиков.
-
-![Команда](https://github.com/yandex-praktikum/mf.messenger.praktikum.yandex.images/blob/master/mf/team.png)
-
-### **Примеры README**
-
-- «[Реакт](https://github.com/facebook/react)»,
-- «[Эхо](https://github.com/labstack/echo)»,
-- «[Вебпак](https://github.com/webpack/webpack)»,
-- «[ТДенгине](https://github.com/taosdata/TDengine)»,
-- «[Соул-хантинг](https://github.com/vladpereskokov/soul-hunting/)».
diff --git a/netlify.toml b/netlify.toml
new file mode 100644
index 000000000..1bb12aa37
--- /dev/null
+++ b/netlify.toml
@@ -0,0 +1,7 @@
+[build]
+ publish = "dist"
+
+[[redirects]]
+ from = "/*"
+ to = "/index.html"
+ status = 200
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 000000000..9151dee19
--- /dev/null
+++ b/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "yamess",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "engines": {
+ "node": ">=18.14.0"
+ },
+ "scripts": {
+ "clean": "rm -rf dist",
+ "start": "tsc && vite build && node src/server.js",
+ "dev": "vite",
+ "build": "npm run clean && tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@types/express": "^4.17.21",
+ "express": "^4.18.3",
+ "handlebars": "^4.7.6"
+ },
+ "devDependencies": {
+ "@typescript-eslint/eslint-plugin": "^7.1.0",
+ "@typescript-eslint/parser": "^7.1.0",
+ "eslint": "^8.57.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-prettier": "^5.1.3",
+ "nanoid": "^5.0.6",
+ "postcss-nested": "^6.0.1",
+ "prettier": "3.2.5",
+ "stylelint": "^16.3.1",
+ "stylelint-config-standard": "^36.0.0",
+ "typescript": "^5.3.3",
+ "vite": "^5.1.4",
+ "vite-plugin-eslint": "^1.8.1",
+ "vite-plugin-stylelint": "^5.3.1"
+ }
+}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 000000000..361dbff56
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,5 @@
+import postcssNested from 'postcss-nested'
+
+export default {
+ plugins: [postcssNested],
+}
diff --git a/src/components/avatar/avatar.css b/src/components/avatar/avatar.css
new file mode 100644
index 000000000..cedac5337
--- /dev/null
+++ b/src/components/avatar/avatar.css
@@ -0,0 +1,47 @@
+.avatar {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 130px;
+ height: 130px;
+ overflow: hidden;
+ border-radius: 50%;
+
+ &__text {
+ display: none;
+ position: absolute;
+ cursor: pointer;
+ }
+
+ &__img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: center;
+ background: var(--hover-bg);
+ }
+
+ &:hover {
+ .avatar__text {
+ background: var(--hover-bg);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ width: 100%;
+
+ span {
+ font-size: 18px;
+ text-align: center;
+ }
+ }
+
+ .avatar__img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: center;
+ }
+ }
+}
diff --git a/src/components/avatar/avatar.ts b/src/components/avatar/avatar.ts
new file mode 100644
index 000000000..c7905ec3d
--- /dev/null
+++ b/src/components/avatar/avatar.ts
@@ -0,0 +1,55 @@
+import Block, { Props } from '@/core/Block'
+import './avatar.css'
+
+// language=hbs
+const avatarTemplate = `
+
+
+ {{#if canChange}}
+
+ {{{ changeContent }}}
+
+ {{/if}}
+
+`
+
+type AvatarProps = {
+ src: string
+ alt: string
+ size?: string
+ className?: string
+ canChange?: boolean
+ changeContent?: string
+} & Props
+
+export default class Avatar extends Block {
+ constructor(props: AvatarProps) {
+ if (!props.src) {
+ props.src =
+ 'https://i2.wp.com/vdostavka.ru/wp-content/uploads/2019/05/no-avatar.png?fit=512%2C512&ssl=1'
+ }
+
+ super(props)
+
+ if (!this.props.canChange) {
+ this.props.canChange = false
+ }
+ if (!this.props.changeContent) {
+ this.props.changeContent = 'Поменять аватар'
+ }
+
+ const element = this.element as HTMLElement
+
+ if (props.size) {
+ element.style.width = props.size
+ element.style.height = props.size
+ } else {
+ element.style.width = '130px'
+ element.style.height = '130px'
+ }
+ }
+
+ render() {
+ return this.compile(avatarTemplate, this.props)
+ }
+}
diff --git a/src/components/button/button.css b/src/components/button/button.css
new file mode 100644
index 000000000..e88e99eef
--- /dev/null
+++ b/src/components/button/button.css
@@ -0,0 +1,17 @@
+.button {
+ width: 100%;
+ padding: 12px 0;
+ background: var(--color-blue);
+ color: var(--color-white);
+ border: none;
+ border-radius: var(--border-radius);
+ cursor: pointer;
+ font-size: 13px;
+}
+
+.button-icon {
+ background: none;
+ border: none;
+ outline: none;
+ width: auto;
+}
diff --git a/src/components/button/button.ts b/src/components/button/button.ts
new file mode 100644
index 000000000..080e3dadc
--- /dev/null
+++ b/src/components/button/button.ts
@@ -0,0 +1,20 @@
+import Block, { Props } from '@/core/Block'
+import './button.css'
+
+// language=hbs
+const buttonTemplate: string = `{{{ label }}} `
+
+type ButtonProps = {
+ label: string
+ className?: string
+} & Props
+
+export default class Button extends Block {
+ constructor(props: ButtonProps) {
+ super(props)
+ }
+
+ render() {
+ return this.compile(buttonTemplate, this.props)
+ }
+}
diff --git a/src/components/chatItem/chatItem.css b/src/components/chatItem/chatItem.css
new file mode 100644
index 000000000..31d1c9b44
--- /dev/null
+++ b/src/components/chatItem/chatItem.css
@@ -0,0 +1,58 @@
+.chat-item {
+ display: grid;
+ grid-template-columns: 50px auto 40px;
+ grid-template-rows: 20px auto;
+ column-gap: 10px;
+ align-items: center;
+ padding: 12px 10px;
+ overflow: hidden;
+ border-top: 1px solid var(--border);
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.3s;
+
+ &:hover {
+ background: var(--blue-bg);
+ }
+
+ &__avatar {
+ grid-row: 1/3;
+ }
+
+ &__nickname {
+ font-weight: 600;
+ font-size: 14px;
+ }
+
+ &__date {
+ color: var(--color-gray);
+ font-weight: 400;
+ font-size: 12px;
+ place-self: center center;
+ }
+
+ &__message {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 13px;
+ font-weight: 400;
+ }
+
+ &__unreaded {
+ width: 20px;
+ height: 20px;
+ justify-self: center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2px;
+ background: var(--color-blue);
+ border-radius: 50%;
+ color: var(--color-white);
+ font-size: 12px;
+ }
+}
diff --git a/src/components/chatItem/chatItem.ts b/src/components/chatItem/chatItem.ts
new file mode 100644
index 000000000..6565dc6ef
--- /dev/null
+++ b/src/components/chatItem/chatItem.ts
@@ -0,0 +1,49 @@
+import Avatar from '@/components/avatar/avatar.ts'
+import { Chat } from '@/constants/types.ts'
+import Block, { Props } from '@/core/Block'
+import './chatItem.css'
+import store from '@/core/Store.ts'
+import formatMessageDate from '@/utils/formatMessageDate.ts'
+import getResourceURL from '@/utils/getResourceURL.ts'
+
+// language=hbs
+const ChatItemTemplate = `
+
+ {{{ avatar }}}
+ {{{ title }}}
+ {{{ time }}}
+ {{{ last_message.content }}}
+ {{#if unreaded}}
+ {{{ unreaded }}}
+ {{/if}}
+
+`
+
+export type ChatItemProps = Chat & Props
+
+export default class ChatItem extends Block {
+ constructor(props: ChatItemProps) {
+ super({
+ ...props,
+ events: {
+ click: () => {
+ store.set('selectedChat', props.id)
+ },
+ },
+ time: props.last_message
+ ? formatMessageDate(props.last_message.time)
+ : '',
+ unreaded: props.unread_count ? props.unread_count : '',
+ avatar: new Avatar({
+ src: props.avatar ? getResourceURL(props.avatar) : '',
+ alt: 'avatar',
+ size: '50px',
+ className: 'chat-item__avatar',
+ }),
+ })
+ }
+
+ render() {
+ return this.compile(ChatItemTemplate, this.props)
+ }
+}
diff --git a/src/components/chatWindow/chatWindow.css b/src/components/chatWindow/chatWindow.css
new file mode 100644
index 000000000..3914e9800
--- /dev/null
+++ b/src/components/chatWindow/chatWindow.css
@@ -0,0 +1,111 @@
+.chat {
+ display: grid;
+ grid-template-rows: 44px auto 64px;
+ padding: 10px 0;
+ gap: 4px;
+ max-height: 100%;
+ overflow-y: hidden;
+ scrollbar-width: thin;
+ box-sizing: border-box;
+
+ &__header {
+ display: grid;
+ grid-template-columns: 34px auto 44px;
+ padding: 0 20px 10px;
+ gap: 16px;
+ align-items: center;
+ border-bottom: 1px solid var(--border);
+ z-index: 1000;
+
+ .chat-menu {
+ position: relative;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &__actions {
+ display: none;
+ position: absolute;
+ width: 240px;
+ background: var(--color-white);
+ box-shadow: var(--box-shadow);
+ padding: 12px;
+ border-radius: 10px;
+
+ i {
+ color: var(--color-blue);
+ }
+ }
+
+ &__action {
+ display: flex;
+ align-items: center;
+ padding: 8px;
+ gap: 8px;
+ cursor: pointer;
+ border-radius: 6px;
+ background: none;
+ border: none;
+ font-size: 14px;
+
+ &:hover {
+ background: var(--color1);
+ }
+ }
+ }
+
+ .chat-menu:hover .chat-menu__actions {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ top: 30px;
+ right: 12px;
+ }
+ }
+
+ &__messages {
+ display: flex;
+ flex-direction: column-reverse;
+ gap: 20px;
+ height: 100%;
+ padding: 0 20px;
+ overflow-y: scroll;
+ scrollbar-width: thin;
+ }
+
+ &__input-block {
+ display: grid;
+ grid-template-columns: 30px auto 30px;
+ gap: 12px;
+ align-items: center;
+ padding: 10px 20px 0;
+ border-top: 1px solid var(--border);
+
+ .message-label {
+ margin: 0;
+ }
+
+ .message-input {
+ padding: 12px 10px;
+ background: var(--color1);
+ border: none;
+ border-radius: 100px;
+ box-sizing: border-box;
+ }
+
+ .lni-paperclip {
+ color: var(--color-gray);
+ font-size: 20px;
+ transform: rotate(45deg);
+ }
+
+ .send-btn {
+ height: 40px;
+ width: 40px;
+ background: var(--color1);
+ border-radius: 50%;
+ cursor: pointer;
+ }
+ }
+}
diff --git a/src/components/chatWindow/chatWindow.ts b/src/components/chatWindow/chatWindow.ts
new file mode 100644
index 000000000..818c66db5
--- /dev/null
+++ b/src/components/chatWindow/chatWindow.ts
@@ -0,0 +1,344 @@
+import Avatar from '@/components/avatar/avatar.ts'
+import Button from '@/components/button/button.ts'
+import Input from '@/components/input/input.ts'
+import { MessageItem, MessageItemProps } from '@/components/message/message.ts'
+import { Modal } from '@/components/modal/modal.ts'
+import { Chat, User } from '@/constants/types.ts'
+import { ChatController } from '@/controllers/ChatController.ts'
+import Block, { Props } from '@/core/Block'
+import store from '@/core/Store.ts'
+import connect from '@/utils/connect.ts'
+import connectToMessageSocket from '@/utils/connectToMessageSocket.ts'
+import getResourceURL from '@/utils/getResourceURL.ts'
+import './chatWindow.css'
+
+// language=hbs
+const ChatTemplate: string = `
+
+ {{#if selectedChat}}
+
+
+
+
+ {{{ messages }}}
+
+
+
+
+
+
+
+ {{{ messageInput }}}
+ {{{ sendMessageBtn }}}
+
+ {{/if}}
+
+`
+
+type ChatWindowProps = {
+ selectedChat: number
+} & Props
+
+const chatController = new ChatController()
+
+export class ChatWindow extends Block {
+ private socket: WebSocket | null = null
+ private chat: Chat | null = null
+ private modal: Modal
+ private isChatAdmin: boolean = false
+
+ constructor(props: ChatWindowProps) {
+ super(props)
+
+ this.modal = new Modal()
+ this.children.addUserBtn = new Button({
+ label: ' Добавить пользователя',
+ className: 'chat-menu__action',
+ withId: true,
+ events: {
+ click: () => {
+ this.showAddUserModal()
+ },
+ },
+ })
+ this.children.deleteUserBtn = new Button({
+ label: ' Удалить пользователя',
+ className: 'chat-menu__action',
+ withId: true,
+ events: {
+ click: () => {
+ this.showDeleteUserModal()
+ },
+ },
+ })
+ this.children.deleteChatBtn = new Button({
+ label: ' Удалить чат',
+ className: 'chat-menu__action',
+ withId: true,
+ events: {
+ click: () => {
+ if (this.chat) {
+ chatController.deleteChat(this.chat.id)
+ }
+ },
+ },
+ })
+
+ this.children.sendMessageBtn = new Button({
+ label: ' ',
+ className: 'button-icon send-btn',
+ withId: true,
+ events: {
+ click: () => {
+ this.sendMessage()
+ },
+ },
+ })
+
+ this.isChatAdmin = this.checkUserIsChatAdmin()
+ }
+
+ checkUserIsChatAdmin() {
+ if (this.props.chatUsers && Array.isArray(this.props.chatUsers)) {
+ const user = this.props.chatUsers.filter((user: User) => {
+ return user.id === store.getState().userdata.id
+ })[0]
+
+ const element = this.children.deleteChatBtn.element as HTMLElement
+
+ if (user && user.role !== 'admin') {
+ element.style.display = 'none'
+ } else {
+ element.style.display = 'flex'
+ }
+
+ return user && user.role === 'admin'
+ } else {
+ return false
+ }
+ }
+
+ uploadChatAvatarHandler(isChatAdmin: boolean) {
+ if (!isChatAdmin) {
+ return
+ }
+
+ const fileInput = document.createElement('input')
+ fileInput.type = 'file'
+
+ fileInput.onchange = async (e: Event) => {
+ if (
+ e.currentTarget instanceof HTMLInputElement &&
+ e.currentTarget.files
+ ) {
+ const formData = new FormData()
+
+ formData.append('avatar', e.currentTarget.files[0])
+ formData.append('chatId', `${store.getState().selectedChat}`)
+
+ chatController.uploadChatAvatar(formData).then(() => {
+ chatController.getChats().then(() => {
+ this.chat = store
+ .getState()
+ .chats.filter((chat) => chat.id === this.props.selectedChat)[0]
+
+ if (this.chat) {
+ this.setProps({
+ currentChat: { ...this.chat, src: this.chat.avatar },
+ })
+ }
+ })
+ })
+ }
+ }
+
+ fileInput.click()
+ }
+
+ updateChatAvatar(chat: Chat) {
+ this.children.avatar = new Avatar({
+ src: chat.avatar ? getResourceURL(chat.avatar) : '',
+ alt: chat.avatar ? (this.props.title as string) : '',
+ size: '40px',
+ withId: true,
+ canChange: this.isChatAdmin,
+ changeContent: ` `,
+ events: {
+ click: () => {
+ this.uploadChatAvatarHandler(this.isChatAdmin)
+ },
+ },
+ })
+ }
+
+ sendMessage() {
+ const input = (this.children.messageInput as Input)._inputElement
+ const message = { content: input.getValue(), type: 'message' }
+ if (input.getValue().length && input.element instanceof HTMLInputElement) {
+ this.socket?.send(JSON.stringify(message))
+ input.element.value = ''
+ }
+ }
+
+ createSocket() {
+ const userId = store.getState().userdata.id
+ const chatId = store.getState().selectedChat
+
+ connectToMessageSocket(userId, chatId).then((resp) => {
+ if (resp !== undefined) {
+ this.socket = resp
+ chatController.getChatUsers(chatId)
+
+ this.chat = store
+ .getState()
+ .chats.filter((chat) => chat.id === this.props.selectedChat)[0]
+
+ this.props.title = this.chat.title
+ this.updateChatAvatar(this.chat)
+
+ this.children.messageInput = new Input({
+ type: 'text',
+ name: 'message',
+ label: '',
+ placeholder: 'Сообщение...',
+ className: 'message-label',
+ classNameInput: 'message-input',
+ events: {
+ keyup: (event) => {
+ if (
+ event instanceof KeyboardEvent &&
+ event.target instanceof HTMLInputElement &&
+ event.key === 'Enter'
+ ) {
+ const message = { content: event.target.value, type: 'message' }
+ if (!message.content.length) {
+ return
+ }
+ this.socket?.send(JSON.stringify(message))
+ event.target.value = ''
+ }
+ },
+ },
+ })
+ }
+ })
+ }
+
+ showAddUserModal() {
+ const content = document.createElement('div')
+ const input = new Input({
+ type: 'text',
+ label: 'ID юзера',
+ placeholder: 'ID...',
+ name: 'id',
+ })
+ const btn = new Button({
+ label: 'Добавить',
+ className: 'button input-submit',
+ events: {
+ click: () => {
+ chatController.addUserToChat({
+ users: [Number(input.getValue())],
+ chatId: Number(this.props.selectedChat),
+ })
+ this.closeModal()
+ },
+ },
+ })
+
+ content.appendChild(input.element)
+ content.appendChild(btn.element)
+
+ this.modal.setContent('Добавить пользователя', content)
+ this.modal.open()
+ }
+
+ showDeleteUserModal() {
+ const content = document.createElement('div')
+ content.className = 'users'
+ ;(this.props.chatUsers as User[]).map((user: User) => {
+ const userBlock = document.createElement('div')
+ const userLogin = document.createElement('span')
+ const deleteBtn = document.createElement('button')
+
+ userBlock.className = 'user'
+ userLogin.innerHTML = `${user.login} `
+ userLogin.className = 'user__login'
+ deleteBtn.innerHTML = `☒`
+ deleteBtn.className = 'user__delete-btn'
+ deleteBtn.addEventListener('click', () => {
+ chatController.deleteUserFromChat({
+ users: [user.id],
+ chatId: Number(this.props.selectedChat),
+ })
+ this.closeModal()
+ })
+
+ userBlock.appendChild(userLogin)
+ userBlock.appendChild(deleteBtn)
+
+ content.appendChild(userBlock)
+ })
+
+ this.modal.setContent('Удалить пользователя', content)
+ this.modal.open()
+ }
+
+ closeModal() {
+ this.modal.close()
+ }
+
+ showMessages(messages: MessageItemProps[]) {
+ return messages.map((message) => {
+ return new MessageItem({
+ ...message,
+ isMy: message.user_id === store.getState().userdata.id,
+ user: store
+ .getState()
+ .chatUsers.filter((user) => user.id === message.user_id)[0],
+ })
+ })
+ }
+
+ componentDidUpdate(oldProps: Props, newProps: Partial): boolean {
+ if (this.socket && oldProps.selectedChat !== newProps.selectedChat) {
+ this.socket.close()
+ }
+ if (oldProps.selectedChat !== newProps.selectedChat) {
+ this.createSocket()
+ }
+
+ this.blockArrays.messages = this.showMessages(store.getState().messages)
+ this.isChatAdmin = this.checkUserIsChatAdmin()
+
+ if (newProps.currentChat) {
+ this.updateChatAvatar(newProps.currentChat as Chat)
+ }
+
+ return super.componentDidUpdate(oldProps, newProps)
+ }
+
+ render() {
+ return this.compile(ChatTemplate, this.props)
+ }
+}
+
+export const chatWindow = connect((state) => ({
+ selectedChat: state.selectedChat,
+ messages: state.messages,
+ chatUsers: state.chatUsers,
+ currentChat: state.currentChat,
+}))(ChatWindow)
diff --git a/src/components/form/form.ts b/src/components/form/form.ts
new file mode 100644
index 000000000..077e95c35
--- /dev/null
+++ b/src/components/form/form.ts
@@ -0,0 +1,66 @@
+import Block, { Props } from '@/core/Block'
+import Button from '../button/button'
+import Input from '../input/input'
+
+// language=hbs
+const FormTemplate: string = `
+
+`
+
+export type FormProps = {
+ inputs: Input[]
+ submitBtn: Button
+ className?: string
+} & Props
+
+export default class Form extends Block {
+ inputs: Input[]
+
+ constructor(props: FormProps) {
+ super(props)
+ this.inputs = props.inputs
+ }
+
+ showInputError(inputName: string, error: string) {
+ const input = this.inputs.filter((input) => input.name === inputName)[0]
+ input.showError(error)
+ }
+
+ getValues(): Record | boolean {
+ const data: { [index: string]: string } = {}
+
+ this.blockArrays.inputs.forEach((input) => {
+ if (input instanceof Input) {
+ const isValid = input.validate()
+ if (isValid) {
+ data[input.name] = input.getValue()
+ }
+ }
+ })
+
+ if (Object.keys(data).length) {
+ console.log(data)
+ return data
+ } else {
+ console.log('Форма содержит ошибки')
+ return false
+ }
+ }
+
+ setValues(inputsValues: { [inputName: string]: string }) {
+ Object.entries(inputsValues).forEach(([key, value]) => {
+ const targetInput = this.inputs.filter((input) => input.name === key)[0]
+ if (targetInput) {
+ targetInput.setValue(value)
+ }
+ })
+ }
+
+ render() {
+ console.log('render form')
+ return this.compile(FormTemplate, this.props)
+ }
+}
diff --git a/src/components/input/input.css b/src/components/input/input.css
new file mode 100644
index 000000000..726106d13
--- /dev/null
+++ b/src/components/input/input.css
@@ -0,0 +1,45 @@
+.label {
+ display: block;
+ margin-bottom: 12px;
+ font-size: 10px;
+ font-weight: 300;
+}
+
+.input {
+ width: 100%;
+ padding: 4px 0 8px;
+ font-size: 16px;
+ font-weight: 300;
+ border: none;
+ border-bottom: 1px solid var(--color-blue);
+ outline: none;
+
+ &_error {
+ border-bottom: 1px solid var(--color-red);
+ }
+
+ &-submit {
+ padding: 12px 0;
+ border: none;
+ font-size: 13px;
+ }
+}
+
+.error {
+ display: block;
+ margin-top: 2px;
+ color: var(--color-red);
+ font-size: 10px;
+ font-weight: 400;
+}
+
+.input-round {
+ width: 100%;
+ padding: 10px 20px;
+ background: var(--color1);
+ color: var(--color-gray);
+ box-sizing: border-box;
+ border-radius: 30px;
+ border: none;
+ outline: none;
+}
diff --git a/src/components/input/input.ts b/src/components/input/input.ts
new file mode 100644
index 000000000..a7e34e69e
--- /dev/null
+++ b/src/components/input/input.ts
@@ -0,0 +1,153 @@
+import Block, { Props } from '@/core/Block'
+import './input.css'
+
+// language=hbs
+const inputTemplate: string = `
+
+ {{ label }}
+ {{{ input }}}
+ {{ errorText }}
+
+`
+
+type InputValidation = {
+ required?: boolean
+ regExp?: RegExp
+ errorText?: string
+}
+
+type InputProps = {
+ name: string
+ type: 'text' | 'password' | 'submit'
+ label: string
+ placeholder?: string
+ value?: string
+ className?: string
+ classNameInput?: string
+ validation?: InputValidation
+} & Props
+
+export default class Input extends Block {
+ _name: string
+ _inputElement: InputField
+ _validation: InputValidation | undefined
+
+ constructor(props: InputProps) {
+ const input = new InputField({ ...props })
+ super({
+ ...props,
+ input: input,
+ })
+ input.props._parentBlock = this
+ this._name = props.name
+ this._inputElement = input
+ this._validation = props.validation
+ }
+
+ componentDidUpdate(oldProps: Props, newProps: Partial): boolean {
+ if (this._inputElement.getValue()) {
+ this._inputElement.setProps({
+ ...newProps,
+ value: this._inputElement.getValue(),
+ })
+ } else {
+ this._inputElement.setProps(newProps)
+ }
+ return super.componentDidUpdate(oldProps, newProps)
+ }
+
+ get name() {
+ return this._name
+ }
+
+ getValue() {
+ return this._inputElement.getValue()
+ }
+
+ setValue(value: string) {
+ this._inputElement.setValue(value)
+ }
+
+ validate() {
+ return this._inputElement.validate()
+ }
+
+ showError(error: string) {
+ this.props.errorText = error
+ }
+
+ render() {
+ return this.compile(inputTemplate, this.props)
+ }
+}
+
+// language=hbs
+const inputFieldTemplate: string = `
+
+`
+
+class InputField extends Block {
+ _validation?: InputValidation
+
+ constructor(props: Omit) {
+ super({
+ ...props,
+ events: {
+ blur: () => {
+ this.validate()
+ this.toggleErrorClass()
+ },
+ },
+ })
+ if (props.validation) {
+ this._validation = props.validation
+ }
+ }
+
+ getValue() {
+ const element = this.element as HTMLInputElement
+ return element.value
+ }
+
+ setValue(value: string) {
+ const element = this.element as HTMLInputElement
+ element.value = value
+ }
+
+ toggleErrorClass() {
+ this.element.classList.toggle('input_error', !this.validate())
+ }
+
+ validate() {
+ const parent = this.props._parentBlock as Input
+ const value = this.getValue()
+
+ if (!this._validation) {
+ return true
+ }
+
+ if (this._validation.required && !value.length) {
+ parent.props.errorText = 'Необходимо заполнить это поле'
+ return false
+ } else if (
+ this._validation.regExp &&
+ !this._validation.regExp.test(value)
+ ) {
+ parent.props.errorText = this._validation.errorText
+ return false
+ }
+
+ parent.props.errorText = ''
+ return true
+ }
+
+ render() {
+ return this.compile(inputFieldTemplate, this.props)
+ }
+}
diff --git a/src/components/link/link.css b/src/components/link/link.css
new file mode 100644
index 000000000..1c4a9a508
--- /dev/null
+++ b/src/components/link/link.css
@@ -0,0 +1,19 @@
+.link {
+ color: var(--color-blue);
+ font-size: 13px;
+ cursor: pointer;
+ text-decoration: none;
+ font-weight: 500;
+
+ &_blue {
+ color: var(--color-blue);
+ }
+
+ &_red {
+ color: var(--color-red);
+ }
+
+ &_gray {
+ color: var(--color-gray);
+ }
+}
diff --git a/src/components/link/link.ts b/src/components/link/link.ts
new file mode 100644
index 000000000..df4527265
--- /dev/null
+++ b/src/components/link/link.ts
@@ -0,0 +1,29 @@
+import Block, { Props } from '@/core/Block'
+import './link.css'
+import router from '@/router.ts'
+
+// language=hbs
+const LinkTemplate = `{{ label }} `
+
+type LinkProps = {
+ to: string
+ label: string
+ className?: string
+} & Props
+
+export default class Link extends Block {
+ constructor(props: LinkProps) {
+ super({
+ ...props,
+ events: {
+ click: () => {
+ router.go(props.to)
+ },
+ },
+ })
+ }
+
+ render() {
+ return this.compile(LinkTemplate, this.props)
+ }
+}
diff --git a/src/components/message/message.css b/src/components/message/message.css
new file mode 100644
index 000000000..f08dcb497
--- /dev/null
+++ b/src/components/message/message.css
@@ -0,0 +1,44 @@
+.message {
+ position: relative;
+ display: grid;
+ grid-template-columns: 30px auto;
+ grid-auto-rows: 16px auto;
+ gap: 4px 12px;
+ padding: 2px 12px 12px;
+ width: calc(50% - 40px);
+ border-radius: 10px;
+ background: var(--message-bg);
+
+ &_my {
+ background: var(--blue-bg);
+ align-self: flex-end;
+ }
+
+ &__nickname {
+ padding: 4px 0 0;
+ position: relative;
+ grid-column: 1/3;
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: capitalize;
+ }
+
+ &__avatar {
+ position: relative;
+ }
+
+ &__text {
+ font-size: 15px;
+ font-weight: 400;
+ }
+
+ &__date {
+ position: absolute;
+ bottom: 4px;
+ right: 4px;
+ color: var(--color-gray);
+ font-weight: 400;
+ font-size: 12px;
+ place-self: end end;
+ }
+}
diff --git a/src/components/message/message.ts b/src/components/message/message.ts
new file mode 100644
index 000000000..5232eddb5
--- /dev/null
+++ b/src/components/message/message.ts
@@ -0,0 +1,52 @@
+import Avatar from '@/components/avatar/avatar.ts'
+import { Message, User } from '@/constants/types.ts'
+import Block from '@/core/Block'
+import './message.css'
+import formatMessageDate from '@/utils/formatMessageDate.ts'
+import getResourceURL from '@/utils/getResourceURL.ts'
+
+// language=hbs
+const MessageTemplate = `
+ {{#if isMessage}}
+
+
{{ nickname }}
+
{{{ avatar }}}
+
{{ content }}
+
{{ date }}
+
+ {{else}}
+ {{content}}
+ {{/if}}
+`
+
+export type MessageItemProps = {
+ isMy: boolean
+ isMessage?: boolean
+ user: User
+} & Message
+
+export class MessageItem extends Block {
+ constructor(props: MessageItemProps) {
+ super({
+ ...props,
+ avatar: new Avatar({
+ src:
+ props.user && props.user.avatar
+ ? getResourceURL(props.user.avatar)
+ : '',
+ alt: props.user ? props.user.display_name : '',
+ size: '32px',
+ }),
+ nickname: props.user ? props.user.display_name : '',
+ isMessage: props.type === 'message',
+ date: formatMessageDate(props.time),
+ })
+ if (props.isMy) {
+ this.element.classList.add('message_my')
+ }
+ }
+
+ render() {
+ return this.compile(MessageTemplate, this.props)
+ }
+}
diff --git a/src/components/modal/modal.css b/src/components/modal/modal.css
new file mode 100644
index 000000000..aa51eac37
--- /dev/null
+++ b/src/components/modal/modal.css
@@ -0,0 +1,47 @@
+.overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgb(200 200 200 / 50%);
+ z-index: 1000;
+ display: none;
+}
+
+.modal {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ min-width: 300px;
+ padding: 40px 30px;
+ transform: translate(-50%, -50%);
+ background-color: var(--color-white);
+ border-radius: 8px;
+ box-shadow: var(--box-shadow);
+ z-index: 1001;
+ display: none;
+ font-family: var(--font-family);
+}
+
+.modal-content {
+ position: relative;
+
+ &__title {
+ margin-bottom: 32px;
+ font-size: 18px;
+ text-align: center;
+ }
+
+ &__close-button {
+ position: absolute;
+ right: -20px;
+ top: -34px;
+ font-size: 24px;
+ cursor: pointer;
+ }
+
+ button {
+ margin-top: 16px;
+ }
+}
diff --git a/src/components/modal/modal.ts b/src/components/modal/modal.ts
new file mode 100644
index 000000000..767535996
--- /dev/null
+++ b/src/components/modal/modal.ts
@@ -0,0 +1,60 @@
+import './modal.css'
+
+export class Modal {
+ private modalElement: HTMLElement
+ private overlayElement: HTMLElement
+
+ constructor() {
+ this.modalElement = document.createElement('div')
+ this.modalElement.className = 'modal'
+ this.modalElement.innerHTML = `
+
+
×
+
Modal Title
+
Your content goes here.
+
+ `
+
+ this.overlayElement = document.createElement('div')
+ this.overlayElement.className = 'overlay'
+
+ document.body.appendChild(this.overlayElement)
+ document.body.appendChild(this.modalElement)
+
+ this.bindEvents()
+ }
+
+ setContent(title: string, content: HTMLElement | DocumentFragment): void {
+ const titleElement = this.modalElement.querySelector('h2')
+ const contentElement = this.modalElement.querySelector(
+ '.modal-content__content'
+ )
+
+ if (titleElement) {
+ titleElement.textContent = title
+ }
+
+ if (contentElement) {
+ contentElement.innerHTML = '' // Clear existing content
+ contentElement.appendChild(content) // Append the new content
+ }
+ }
+
+ private bindEvents(): void {
+ const closeButton = this.modalElement.querySelector(
+ '.modal-content__close-button'
+ ) as HTMLElement
+ closeButton.addEventListener('click', () => this.close())
+ this.overlayElement.addEventListener('click', () => this.close())
+ }
+
+ open(): void {
+ this.modalElement.style.display = 'block'
+ this.overlayElement.style.display = 'block'
+ }
+
+ close(): void {
+ this.modalElement.style.display = 'none'
+ this.overlayElement.style.display = 'none'
+ }
+}
diff --git a/src/constants/api.ts b/src/constants/api.ts
new file mode 100644
index 000000000..ed94f9739
--- /dev/null
+++ b/src/constants/api.ts
@@ -0,0 +1 @@
+export const BASE_URL = 'https://ya-praktikum.tech/api/v2'
diff --git a/src/constants/initialState.ts b/src/constants/initialState.ts
new file mode 100644
index 000000000..75753ece4
--- /dev/null
+++ b/src/constants/initialState.ts
@@ -0,0 +1,17 @@
+export const initialState = {
+ userdata: {
+ id: 0,
+ avatar: '',
+ email: '',
+ login: '',
+ first_name: '',
+ second_name: '',
+ display_name: '',
+ phone: '',
+ },
+ chats: [],
+ selectedChat: 0,
+ messages: [],
+ chatUsers: [],
+ currentChat: null,
+}
diff --git a/src/constants/routes.ts b/src/constants/routes.ts
new file mode 100644
index 000000000..bae94c11f
--- /dev/null
+++ b/src/constants/routes.ts
@@ -0,0 +1,10 @@
+export enum routes {
+ login = '/',
+ register = '/sign-up',
+ messenger = '/messenger',
+ profile = '/profile',
+ editUserdata = '/edit-userdata',
+ editPassword = '/edit-password',
+ notFound = '/not-found',
+ serverError = '/server-error',
+}
diff --git a/src/constants/types.ts b/src/constants/types.ts
new file mode 100644
index 000000000..32d35c5e9
--- /dev/null
+++ b/src/constants/types.ts
@@ -0,0 +1,40 @@
+export type User = {
+ id: number
+ avatar: string
+ email: string
+ login: string
+ first_name: string
+ second_name: string
+ display_name: string
+ phone: string
+}
+
+export type Chat = {
+ id: number
+ title: string
+ avatar: string | null
+ unread_count: number
+ created_by: number
+ last_message: {
+ content: string
+ id: number
+ time: string
+ user: User
+ }
+}
+
+export type Message = {
+ chat_id: number
+ content: string
+ file: null
+ id: number
+ is_read: boolean
+ time: string
+ type: 'message'
+ user_id: number
+}
+
+export type EditPasswordData = {
+ oldPassword: string
+ newPassword: string
+}
diff --git a/src/constants/validations.ts b/src/constants/validations.ts
new file mode 100644
index 000000000..d6f4ce6be
--- /dev/null
+++ b/src/constants/validations.ts
@@ -0,0 +1,7 @@
+export const ValidationsMap: { [index: string]: RegExp } = {
+ login: /^(?=.*[A-Za-z])[A-Za-z0-9_-]{3,20}$/,
+ password: /^(?=.*[0-9])(?=.*[A-ZА-ЯЁ]).{6,40}$/,
+ email: /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/,
+ name: /^[A-ZА-ЯЁ][a-zа-яё-]{2,20}$/,
+ phone: /^[+]?[\d]{10,15}$/,
+}
diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts
new file mode 100644
index 000000000..8dcb94e1f
--- /dev/null
+++ b/src/controllers/AuthController.ts
@@ -0,0 +1,75 @@
+import store from '@/core/Store.ts'
+import { AuthService, LoginData, RegisterData } from '@/services/AuthService.ts'
+import getResourceURL from '@/utils/getResourceURL.ts'
+
+const authService = new AuthService()
+
+export class AuthController {
+ public async signin(data: LoginData) {
+ return await authService
+ .signin({
+ login: data.login,
+ password: data.password,
+ })
+ .catch((error) => {
+ console.log(error)
+ return error
+ })
+ }
+
+ public async signup(data: RegisterData) {
+ return authService
+ .signup({
+ first_name: data.first_name,
+ second_name: data.second_name,
+ login: data.login,
+ email: data.email,
+ password: data.password,
+ phone: data.phone,
+ })
+ .then((resp) => {
+ if (resp.status === 200) {
+ store.set('userdata', {
+ ...store.getState().userdata,
+ id: resp.response,
+ })
+ }
+ return resp
+ })
+ .catch((error) => {
+ console.log(error)
+ return error
+ })
+ }
+
+ public async getUser() {
+ return authService
+ .getUser()
+ .then((resp) => {
+ if (resp.status === 200) {
+ const data = JSON.parse(resp.response)
+ if (data.avatar) {
+ data.avatar = getResourceURL(data.avatar)
+ }
+ store.set('userdata', data)
+ }
+ return resp
+ })
+ .catch((error) => {
+ console.log(error)
+ return error
+ })
+ }
+
+ public async logout() {
+ return authService
+ .logout()
+ .then((resp) => {
+ return resp
+ })
+ .catch((error) => {
+ console.log(error)
+ return error
+ })
+ }
+}
diff --git a/src/controllers/ChatController.ts b/src/controllers/ChatController.ts
new file mode 100644
index 000000000..f98691a99
--- /dev/null
+++ b/src/controllers/ChatController.ts
@@ -0,0 +1,99 @@
+import store from '@/core/Store.ts'
+import { ChatService, ChatUsersRequest } from '@/services/ChatService.ts'
+
+const chatService = new ChatService()
+
+export class ChatController {
+ public async getChats() {
+ return chatService
+ .getChats()
+ .then((resp) => {
+ store.set('chats', JSON.parse(resp.response))
+ if (store.getState().chats.length) {
+ store.set('selectedChat', store.getState().chats[0].id)
+ }
+ return resp
+ })
+ .catch((error) => {
+ console.log(error)
+ return error
+ })
+ }
+
+ public async getToken(chatId: number) {
+ return chatService
+ .getToken(chatId)
+ .then((resp) => {
+ const currentChat = store
+ .getState()
+ .chats.filter((chat) => chat.id === store.getState().selectedChat)[0]
+ store.set('currentChat', currentChat)
+ return JSON.parse(resp.response)
+ })
+ .catch((error) => {
+ console.log(error)
+ return error
+ })
+ }
+
+ public async getChatUsers(chatId: number) {
+ return chatService
+ .getChatUsers(chatId)
+ .then((resp) => {
+ store.set('chatUsers', JSON.parse(resp.response))
+ })
+ .catch((error) => {
+ console.log(error)
+ return error
+ })
+ }
+
+ public async createChat(title: string) {
+ return chatService
+ .createChat(title)
+ .then(() => {
+ this.getChats()
+ })
+ .catch((error) => {
+ console.log(error)
+ return error
+ })
+ }
+
+ public async deleteChat(chatId: number) {
+ return chatService
+ .deleteChat(chatId)
+ .then(() => {
+ this.getChats()
+ })
+ .catch((error) => {
+ console.log(error)
+ return error
+ })
+ }
+
+ public async uploadChatAvatar(data: FormData) {
+ return chatService.uploadChatAvatar(data).catch((error) => {
+ console.log(error)
+ return error
+ })
+ }
+
+ public async addUserToChat(data: ChatUsersRequest) {
+ return chatService.addUserToChat(data).then(() => {
+ this.getChatUsers(data.chatId).catch((error) => {
+ console.log(error)
+ return error
+ })
+ })
+ }
+
+ public async deleteUserFromChat(data: ChatUsersRequest) {
+ return chatService.deleteUserFromChat(data).then(() => {
+ this.getChatUsers(data.chatId).catch((error) => {
+ console.log(error)
+ return error
+ })
+ })
+ }
+}
diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts
new file mode 100644
index 000000000..a8217de64
--- /dev/null
+++ b/src/controllers/UserController.ts
@@ -0,0 +1,47 @@
+import { EditPasswordData, User } from '@/constants/types.ts'
+import { AuthController } from '@/controllers/AuthController.ts'
+import store from '@/core/Store.ts'
+import { UserService } from '@/services/UserService.ts'
+
+const authController = new AuthController()
+const userService = new UserService()
+
+export class UserController {
+ public async editProfile(userdata: Partial) {
+ return userService
+ .editProfile(userdata)
+ .then((resp) => {
+ store.set('userdata', resp.response)
+ authController.getUser()
+ return resp
+ })
+ .catch((error) => {
+ console.log(error)
+ return error
+ })
+ }
+
+ public async editAvatar(formData: FormData) {
+ userService
+ .editAvatar(formData)
+ .then(() => {
+ authController.getUser()
+ })
+ .catch((error) => {
+ console.log(error)
+ return error
+ })
+ }
+
+ public async editPassword(data: EditPasswordData) {
+ return userService
+ .editPassword(data)
+ .then((resp) => {
+ return resp
+ })
+ .catch((error) => {
+ console.log(error)
+ return error
+ })
+ }
+}
diff --git a/src/core/Block.ts b/src/core/Block.ts
new file mode 100644
index 000000000..0dde9e091
--- /dev/null
+++ b/src/core/Block.ts
@@ -0,0 +1,246 @@
+import Handlebars from 'handlebars'
+import { nanoid } from 'nanoid'
+import EventBus from './EventBus'
+
+type Children = Record
+
+export type Props = {
+ events?: { [eventName: string]: (e: Event) => void }
+ withId?: boolean
+ [prop: string]: unknown
+}
+
+export type PropsAndChildren = {
+ children?: Children
+ props?: Props
+ blockArrays?: Record
+ [prop: string]: unknown
+}
+
+export type BlockArrays = Record
+
+export default class Block {
+ private static EVENTS = {
+ INIT: 'init',
+ FLOW_CDM: 'flow:component-did-mount',
+ FLOW_CDU: 'flow:component-did-update',
+ FLOW_CWU: 'flow:component-will-unmount',
+ FLOW_RENDER: 'flow:render',
+ }
+
+ private _element: Element | null = null
+ protected id: string = ''
+ protected eventBus: () => EventBus
+ props: Props
+ children: Children
+ blockArrays: BlockArrays
+
+ public constructor(propsAndChildren: PropsAndChildren = {}) {
+ const eventBus = new EventBus()
+ this.eventBus = () => eventBus
+ const { children, props, blockArrays } =
+ this._getChildrenAndProps(propsAndChildren)
+
+ if (props.withId) {
+ this.id = nanoid(6)
+ this.props = this._makePropsProxy({ ...props, id: this.id })
+ } else {
+ this.props = this._makePropsProxy(props)
+ }
+
+ this.children = children
+ this.blockArrays = blockArrays
+ this._registerEvents(eventBus)
+ eventBus.emit(Block.EVENTS.INIT)
+ }
+
+ private _registerEvents(eventBus: EventBus) {
+ eventBus.on(Block.EVENTS.INIT, this._init.bind(this))
+ eventBus.on(Block.EVENTS.FLOW_CDM, this._componentDidMount.bind(this))
+ eventBus.on(Block.EVENTS.FLOW_CDU, this._componentDidUpdate.bind(this))
+ eventBus.on(Block.EVENTS.FLOW_CWU, this.componentWillUnmount.bind(this))
+ eventBus.on(Block.EVENTS.FLOW_RENDER, this._render.bind(this))
+ }
+
+ private _createDocumentElement(tagName: string) {
+ const element = document.createElement(tagName)
+ if (this.id) {
+ element.dataset.id = this.id
+ }
+
+ return element
+ }
+
+ private _init() {
+ this.eventBus().emit(Block.EVENTS.FLOW_RENDER)
+ }
+
+ private _componentDidMount() {
+ this.componentDidMount()
+
+ Object.values(this.children).forEach((child) => {
+ child.dispatchComponentDidMount()
+ })
+ }
+
+ componentDidMount() {}
+
+ dispatchComponentDidMount() {
+ this.eventBus().emit(Block.EVENTS.FLOW_CDM)
+ }
+
+ private _componentDidUpdate(oldProps?: Props, newProps?: Props) {
+ if (this.componentDidUpdate(oldProps, newProps)) {
+ this.eventBus().emit(Block.EVENTS.FLOW_RENDER)
+ }
+ }
+
+ componentDidUpdate(oldProps?: Props, newProps?: Partial) {
+ oldProps = { ...oldProps, ...newProps }
+ return true
+ }
+
+ componentWillUnmount() {}
+
+ get element() {
+ if (!this._element) {
+ throw new Error('Нет элемента')
+ }
+ return this._element
+ }
+
+ setProps = (nextProps: Partial) => {
+ if (!nextProps) {
+ return
+ }
+
+ Object.assign(this.props, nextProps)
+ }
+
+ private _render() {
+ const newElement = this.render()
+ if (this._element) {
+ this.removeEvents()
+ this._element.replaceWith(newElement)
+ }
+ this._element = newElement
+ this.addEvents()
+ }
+
+ render(): Element {
+ return document.createElement('div')
+ }
+
+ compile(template: string, props: Props) {
+ const propsAndStubs = { ...props }
+ const listId = nanoid(6)
+
+ Object.entries(this.children).forEach(([key, child]) => {
+ propsAndStubs[key] = `
`
+ })
+
+ Object.keys(this.blockArrays).forEach((key) => {
+ propsAndStubs[key] = `
`
+ })
+
+ const fragment = this._createDocumentElement(
+ 'template'
+ ) as HTMLTemplateElement
+ fragment.innerHTML = Handlebars.compile(template)(propsAndStubs)
+
+ Object.values(this.children).forEach((child) => {
+ const stub = fragment.content.querySelector(`[data-id="${child.id}"]`)
+ if (stub) {
+ stub.replaceWith(child.element)
+ }
+ })
+
+ Object.values(this.blockArrays).forEach((child) => {
+ const listCont = this._createDocumentElement(
+ 'template'
+ ) as HTMLTemplateElement
+
+ child.forEach((item) => {
+ if (item) {
+ listCont.content.append(item.element)
+ } else {
+ listCont.content.append(`${item}`)
+ }
+ })
+
+ const stub = fragment.content.querySelector(`[data-id="__l_${listId}"]`)
+ if (stub) {
+ stub.replaceWith(listCont.content)
+ }
+ })
+
+ if (!fragment.content.firstElementChild) {
+ throw new Error('Нет элемента')
+ }
+
+ return fragment.content.firstElementChild
+ }
+
+ private _getChildrenAndProps(propsAndChildren: PropsAndChildren): {
+ props: Props
+ children: Children
+ blockArrays: BlockArrays
+ } {
+ const children: Children = {}
+ const props: Props = {}
+ const blockArrays: BlockArrays = {}
+
+ Object.entries(propsAndChildren).forEach(([key, value]) => {
+ if (Array.isArray(value)) {
+ blockArrays[key] = value
+ } else if (value instanceof Block) {
+ children[key] = value
+ } else {
+ props[key] = value
+ }
+ })
+
+ return { props, children, blockArrays }
+ }
+
+ private _makePropsProxy(props: Props) {
+ const { eventBus } = this
+
+ return new Proxy(props, {
+ get(target, prop: string) {
+ const value = target[prop]
+ return typeof value === 'function' ? value.bind(target) : value
+ },
+
+ set(target, prop: string, value) {
+ const oldProps = {
+ ...target,
+ }
+ target[prop] = value
+ eventBus().emit(Block.EVENTS.FLOW_CDU, oldProps, target)
+
+ return true
+ },
+ })
+ }
+
+ private removeEvents() {
+ const { events } = this.props
+
+ if (events) {
+ Object.keys(events).forEach((eventName) => {
+ this._element?.removeEventListener(eventName, events[eventName])
+ })
+ }
+ }
+
+ private addEvents() {
+ const { events } = this.props
+
+ if (events) {
+ Object.keys(events).forEach((eventName) => {
+ this.element.addEventListener(eventName, events[eventName])
+ })
+ }
+ }
+}
diff --git a/src/core/EventBus.ts b/src/core/EventBus.ts
new file mode 100644
index 000000000..54ad89cbf
--- /dev/null
+++ b/src/core/EventBus.ts
@@ -0,0 +1,37 @@
+export type Listeners = Record void)[]>
+
+export default class EventBus {
+ private readonly listeners: Listeners = {}
+
+ constructor() {
+ this.listeners = {}
+ }
+
+ on(event: string, callback: () => void): void {
+ if (!this.listeners[event]) {
+ this.listeners[event] = []
+ }
+
+ this.listeners[event].push(callback)
+ }
+
+ off(event: string, callback: () => void): void {
+ if (!this.listeners[event]) {
+ throw new Error(`Нет события: ${event}`)
+ }
+
+ this.listeners[event] = this.listeners[event].filter(
+ (listener) => listener !== callback
+ )
+ }
+
+ emit(event: string, ...args: unknown[]): void {
+ if (!this.listeners[event]) {
+ throw new Error(`Нет события: ${event}`)
+ }
+
+ this.listeners[event].forEach((listener) => {
+ listener(...args)
+ })
+ }
+}
diff --git a/src/core/HTTPTransport.ts b/src/core/HTTPTransport.ts
new file mode 100644
index 000000000..3e31d463a
--- /dev/null
+++ b/src/core/HTTPTransport.ts
@@ -0,0 +1,113 @@
+export enum METHODS {
+ GET = 'GET',
+ POST = 'POST',
+ PUT = 'PUT',
+ DELETE = 'DELETE',
+}
+
+type HTTPMethod = (url: string, options: Options) => Promise
+
+export type Options = {
+ method?: keyof typeof METHODS
+ body?: Record | FormData
+ headers?: Record
+ timeout?: number
+}
+
+function queryStringify(data: { [index: string]: unknown }) {
+ const keysArr = Object.keys(data)
+ const queryArr: string[] = []
+
+ for (let i = 0; i < keysArr.length; i++) {
+ const value = data[keysArr[i]]
+ if (Array.isArray(value)) {
+ queryArr.push(`${keysArr[i]}=${value.join()}`)
+ } else {
+ queryArr.push(`${keysArr[i]}=${value}`)
+ }
+ }
+
+ return `?${queryArr.join('&')}`
+}
+
+export class HTTPTransport {
+ get: HTTPMethod = (url: string, options: Options) => {
+ return this.request(
+ url,
+ { ...options, method: METHODS.GET },
+ options.timeout
+ )
+ }
+
+ post: HTTPMethod = (url: string, options: Options) => {
+ return this.request(
+ url,
+ { ...options, method: METHODS.POST },
+ options.timeout
+ )
+ }
+
+ put: HTTPMethod = (url: string, options: Options) => {
+ return this.request(
+ url,
+ { ...options, method: METHODS.PUT },
+ options.timeout
+ )
+ }
+
+ delete: HTTPMethod = (url: string, options: Options) => {
+ return this.request(
+ url,
+ { ...options, method: METHODS.DELETE },
+ options.timeout
+ )
+ }
+
+ request(url: string, options: Options, timeout = 0): Promise {
+ const { method, headers, body } = options
+
+ if (!method) {
+ throw new Error('Method not implemented')
+ }
+
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest()
+ const xhrURL =
+ method === METHODS.GET &&
+ typeof body === 'object' &&
+ !(body instanceof FormData)
+ ? `${url}${queryStringify(body)}`
+ : url
+
+ xhr.open(method, xhrURL)
+ xhr.withCredentials = true
+ xhr.timeout = timeout
+
+ if (typeof body === 'object' && !(body instanceof FormData)) {
+ xhr.setRequestHeader('Content-Type', 'application/json')
+ }
+
+ for (const key in headers) {
+ xhr.setRequestHeader(key, headers[key])
+ }
+
+ xhr.onload = () => {
+ resolve(xhr)
+ }
+
+ xhr.onabort = reject
+ xhr.onerror = reject
+ xhr.ontimeout = reject
+
+ if (method === METHODS.GET || !body) {
+ xhr.send()
+ } else if (body instanceof FormData) {
+ xhr.send(body)
+ } else {
+ xhr.send(JSON.stringify(body))
+ }
+ })
+ }
+}
+
+export default new HTTPTransport()
diff --git a/src/core/Route.ts b/src/core/Route.ts
new file mode 100644
index 000000000..37596bf15
--- /dev/null
+++ b/src/core/Route.ts
@@ -0,0 +1,46 @@
+import { renderDOM } from '../utils'
+import Block from './Block.ts'
+import { BlockChild } from './Router.ts'
+
+export class Route {
+ _pathname: string
+ _blockClass: BlockChild
+ _block: Block | null
+ _props: {
+ rootQuery: string
+ }
+
+ constructor(
+ pathname: string,
+ view: BlockChild,
+ props: { rootQuery: string }
+ ) {
+ this._pathname = pathname
+ this._blockClass = view
+ this._block = null
+ this._props = props
+ }
+
+ leave() {
+ if (this._block) {
+ this._block.componentWillUnmount()
+ }
+ }
+
+ match(pathname: string) {
+ return pathname === this._pathname
+ }
+
+ render() {
+ if (!this._block) {
+ this._block = this._blockClass
+
+ if (this._block) {
+ renderDOM(this._props.rootQuery, this._block)
+ }
+ return
+ }
+
+ renderDOM(this._props.rootQuery, this._block)
+ }
+}
diff --git a/src/core/Router.ts b/src/core/Router.ts
new file mode 100644
index 000000000..a867721fe
--- /dev/null
+++ b/src/core/Router.ts
@@ -0,0 +1,71 @@
+import { routes } from '@/constants/routes.ts'
+import Block from './Block.ts'
+import { Route } from './Route.ts'
+
+export type BlockChild = InstanceType
+
+export default class Router {
+ routes: Route[] = []
+ history: History = window.history
+ _currentRoute: Route | null = null
+ protected _rootQuery: string = ''
+ protected static __instance: Router
+
+ constructor(rootQuery: string) {
+ if (Router.__instance) {
+ return Router.__instance
+ }
+ this._rootQuery = rootQuery
+ Router.__instance = this
+ }
+
+ use(pathname: string, block: BlockChild) {
+ const route = new Route(pathname, block, { rootQuery: this._rootQuery })
+ this.routes.push(route)
+ return this
+ }
+
+ start() {
+ window.onpopstate = (event: PopStateEvent) => {
+ if (event.currentTarget && event.currentTarget instanceof Window) {
+ const target = event.currentTarget
+ this._onRoute(target.location.pathname)
+ }
+ }
+
+ this._onRoute(window.location.pathname)
+ }
+
+ _onRoute(pathname: string) {
+ const route = this.getRoute(pathname)
+ if (!route) {
+ this.go(routes.notFound)
+ return
+ }
+
+ if (this._currentRoute) {
+ this._currentRoute.leave()
+ }
+
+ this._currentRoute = route
+ route.render()
+ route._block?.dispatchComponentDidMount()
+ }
+
+ go(pathname: string) {
+ this.history.pushState({}, '', pathname)
+ this._onRoute(pathname)
+ }
+
+ back() {
+ this.history.back()
+ }
+
+ forward() {
+ this.history.forward()
+ }
+
+ getRoute(pathname: string) {
+ return this.routes.find((route) => route.match(pathname))
+ }
+}
diff --git a/src/core/Store.ts b/src/core/Store.ts
new file mode 100644
index 000000000..e43b4a080
--- /dev/null
+++ b/src/core/Store.ts
@@ -0,0 +1,43 @@
+import { MessageItemProps } from '@/components/message/message.ts'
+import { initialState } from '@/constants/initialState.ts'
+import { Chat, User } from '@/constants/types.ts'
+import EventBus from './EventBus.ts'
+
+export enum StoreEvents {
+ UPDATED = 'updated',
+}
+
+function set(
+ object: StateType,
+ path: K,
+ value: StateType[K]
+): StateType {
+ if (path in object) {
+ object[path] = value
+ }
+ return object
+}
+
+export type StateType = {
+ userdata: User
+ chats: Chat[]
+ selectedChat: number
+ messages: MessageItemProps[]
+ chatUsers: User[]
+ currentChat: Chat | null
+}
+
+class Store extends EventBus {
+ private state: StateType = initialState
+
+ public getState() {
+ return this.state
+ }
+
+ public set(path: K, value: StateType[K]) {
+ set(this.state, path, value)
+ this.emit(StoreEvents.UPDATED)
+ }
+}
+
+export default new Store()
diff --git a/src/index.html b/src/index.html
new file mode 100644
index 000000000..f3646ede3
--- /dev/null
+++ b/src/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ YaMess
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 000000000..eec39097d
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,23 @@
+import { routes } from '@/constants/routes.ts'
+import { editPasswordPage } from '@/pages/editPasswordPage/editPasswordPage.ts'
+import { editUserdataPage } from '@/pages/editUserdataPage/editUserdataPage.ts'
+import { notFound, serverError } from '@/pages/errorPage/errorPage.ts'
+import { loginPage } from '@/pages/loginPage/loginPage.ts'
+import { messengerPage } from '@/pages/messengerPage/messengerPage.ts'
+import { profilePage } from '@/pages/profilePage/profilePage.ts'
+import { registerPage } from '@/pages/registerPage/registerPage.ts'
+import router from '@/router.ts'
+import './style.css'
+
+document.addEventListener('DOMContentLoaded', () => {
+ router
+ .use(routes.login, loginPage)
+ .use(routes.register, registerPage)
+ .use(routes.messenger, messengerPage)
+ .use(routes.profile, profilePage)
+ .use(routes.editUserdata, editUserdataPage)
+ .use(routes.editPassword, editPasswordPage)
+ .use(routes.notFound, notFound)
+ .use(routes.serverError, serverError)
+ .start()
+})
diff --git a/src/pages/editPasswordPage/editPasswordPage.ts b/src/pages/editPasswordPage/editPasswordPage.ts
new file mode 100644
index 000000000..d342f89b7
--- /dev/null
+++ b/src/pages/editPasswordPage/editPasswordPage.ts
@@ -0,0 +1,114 @@
+import Button from '@/components/button/button'
+import Form from '@/components/form/form'
+import Input from '@/components/input/input'
+import { routes } from '@/constants/routes.ts'
+import { EditPasswordData } from '@/constants/types.ts'
+import { ValidationsMap } from '@/constants/validations.ts'
+import { UserController } from '@/controllers/UserController.ts'
+import Block from '@/core/Block'
+import router from '@/router.ts'
+import '../profilePage/profilePage.css'
+
+// language=hbs
+const EditPasswordPageTemplate = `
+
+
+ {{{ backBtn }}}
+
+
+
+ {{{ editPasswordForm }}}
+
+
+`
+
+type EditPasswordPageProps = {
+ backBtn: Button
+ editPasswordForm: Form
+}
+
+class EditPasswordPage extends Block {
+ constructor(props: EditPasswordPageProps) {
+ super(props)
+ }
+
+ render() {
+ return this.compile(EditPasswordPageTemplate, this.props)
+ }
+}
+
+const userController = new UserController()
+
+const submitHandler = (e: Event) => {
+ e.preventDefault()
+ if (editPasswordForm.getValues()) {
+ const values = editPasswordForm.getValues() as EditPasswordData
+ userController.editPassword(values).then((resp) => {
+ if (resp.status === 200) {
+ router.go(routes.profile)
+ }
+ })
+ }
+}
+
+const editPasswordForm = new Form({
+ className: 'edit-password dialog-form',
+ events: {
+ submit: submitHandler,
+ },
+ inputs: [
+ new Input({
+ type: 'password',
+ name: 'old_password',
+ label: 'Старый пароль',
+ placeholder: 'Старый пароль',
+ validation: {
+ required: true,
+ regExp: ValidationsMap.password,
+ errorText: 'Неверный формат пароля',
+ },
+ }),
+ new Input({
+ type: 'password',
+ name: 'new_password',
+ label: 'Новый пароль',
+ placeholder: 'Новый пароль',
+ validation: {
+ required: true,
+ regExp: ValidationsMap.password,
+ errorText: 'Неверный формат пароля',
+ },
+ }),
+ new Input({
+ type: 'password',
+ name: 'new_password_verify',
+ label: 'Повторите новый пароль',
+ placeholder: 'Повторите новый пароль',
+ validation: {
+ required: true,
+ regExp: ValidationsMap.password,
+ errorText: 'Неверный формат пароля',
+ },
+ }),
+ ],
+ submitBtn: new Input({
+ type: 'submit',
+ name: 'submit',
+ value: 'Сохранить',
+ label: '',
+ classNameInput: 'button input-submit',
+ }),
+})
+
+export const editPasswordPage = new EditPasswordPage({
+ backBtn: new Button({
+ label: ' ',
+ className: 'back__btn',
+ events: {
+ click: () => {
+ router.back()
+ },
+ },
+ }),
+ editPasswordForm: editPasswordForm,
+})
diff --git a/src/pages/editUserdataPage/editUserdataPage.ts b/src/pages/editUserdataPage/editUserdataPage.ts
new file mode 100644
index 000000000..b87ec48f8
--- /dev/null
+++ b/src/pages/editUserdataPage/editUserdataPage.ts
@@ -0,0 +1,153 @@
+import Button from '@/components/button/button'
+import Form from '@/components/form/form'
+import Input from '@/components/input/input'
+import { routes } from '@/constants/routes.ts'
+import { User } from '@/constants/types'
+import { ValidationsMap } from '@/constants/validations'
+import { UserController } from '@/controllers/UserController.ts'
+import Block, { Props } from '@/core/Block'
+import store from '@/core/Store.ts'
+import router from '@/router.ts'
+import '../profilePage/profilePage.css'
+import connect from '@/utils/connect.ts'
+
+const EditUserdataPageTemplate = `
+
+
+ {{{ backBtn }}}
+
+
+
+ {{{ editUserDataForm }}}
+
+
+`
+
+type EditUserdataPageProps = {
+ backBtn: Button
+ editUserDataForm: Form
+}
+
+class EditUserdataPage extends Block {
+ constructor(props: EditUserdataPageProps) {
+ super(props)
+ }
+
+ componentDidUpdate(oldProps?: Props, newProps?: Partial): boolean {
+ editUserdataForm.setValues(
+ store.getState().userdata as Omit
+ )
+ return super.componentDidUpdate(oldProps, newProps)
+ }
+
+ render() {
+ return this.compile(EditUserdataPageTemplate, this.props)
+ }
+}
+
+const userController = new UserController()
+
+const submitHandler = (e: Event) => {
+ e.preventDefault()
+ if (editUserdataForm.getValues()) {
+ const values = editUserdataForm.getValues() as Partial
+ userController.editProfile(values).then((resp) => {
+ if (resp.status === 200) {
+ router.go(routes.profile)
+ }
+ })
+ }
+}
+
+const editUserdataForm = new Form({
+ className: 'edit-userdata dialog-form',
+ events: {
+ submit: submitHandler,
+ },
+ inputs: [
+ new Input({
+ type: 'text',
+ name: 'email',
+ label: 'Почта',
+ placeholder: 'Почта',
+ validation: {
+ regExp: ValidationsMap.email,
+ errorText: 'Неверный формат почты',
+ },
+ }),
+ new Input({
+ type: 'text',
+ name: 'login',
+ label: 'Логин',
+ placeholder: 'Логин',
+ validation: {
+ regExp: ValidationsMap.login,
+ errorText: 'Неверный формат логина',
+ },
+ }),
+ new Input({
+ type: 'text',
+ name: 'first_name',
+ label: 'Имя',
+ placeholder: 'Имя',
+ validation: {
+ regExp: ValidationsMap.name,
+ errorText: 'Неверный формат имени',
+ },
+ }),
+ new Input({
+ type: 'text',
+ name: 'second_name',
+ label: 'Фамилия',
+ placeholder: 'Фамилия',
+ validation: {
+ regExp: ValidationsMap.name,
+ errorText: 'Неверный формат фамилии',
+ },
+ }),
+ new Input({
+ type: 'text',
+ name: 'display_name',
+ label: 'Имя в чате',
+ placeholder: 'Имя в чате',
+ validation: {
+ regExp: ValidationsMap.name,
+ errorText: 'Неверный формат фамилии',
+ },
+ }),
+ new Input({
+ type: 'text',
+ name: 'phone',
+ label: 'Телефон',
+ placeholder: 'Телефон',
+ validation: {
+ regExp: ValidationsMap.phone,
+ errorText: 'Неверный формат телефона',
+ },
+ }),
+ ],
+ submitBtn: new Input({
+ type: 'submit',
+ name: 'submit',
+ value: 'Сохранить',
+ label: '',
+ classNameInput: 'button input-submit',
+ }),
+})
+
+const pageWithUserdata = connect((state) => ({ userdata: state.userdata }))(
+ EditUserdataPage
+)
+
+export const editUserdataPage = new pageWithUserdata({
+ backBtn: new Button({
+ label: ' ',
+ className: 'back__btn',
+ events: {
+ click: () => {
+ router.back()
+ },
+ },
+ }),
+ editUserDataForm: editUserdataForm,
+})
diff --git a/src/pages/errorPage/errorPage.css b/src/pages/errorPage/errorPage.css
new file mode 100644
index 000000000..6ba1b982a
--- /dev/null
+++ b/src/pages/errorPage/errorPage.css
@@ -0,0 +1,7 @@
+.error {
+ flex-direction: column;
+
+ &__description {
+ padding-bottom: 30px;
+ }
+}
diff --git a/src/pages/errorPage/errorPage.ts b/src/pages/errorPage/errorPage.ts
new file mode 100644
index 000000000..580685eaa
--- /dev/null
+++ b/src/pages/errorPage/errorPage.ts
@@ -0,0 +1,48 @@
+import Link from '@/components/link/link'
+import { routes } from '@/constants/routes'
+import Block from '@/core/Block'
+import './errorPage.css'
+
+const errorPageTemplate = `
+
+
{{ errorCode }}
+ {{ errorText }}
+ {{{ backLink }}}
+
+`
+
+type ErrorPageProps = {
+ errorCode: string
+ errorText: string
+ backLink: Link
+}
+
+class ErrorPage extends Block {
+ constructor(props: ErrorPageProps) {
+ super(props)
+ }
+
+ render() {
+ return this.compile(errorPageTemplate, this.props)
+ }
+}
+
+export const notFound = new ErrorPage({
+ errorCode: '404',
+ errorText: 'Не туда попали',
+ backLink: new Link({
+ to: routes.login,
+ label: 'Назад к чатам',
+ withId: true,
+ }),
+})
+
+export const serverError = new ErrorPage({
+ errorCode: '500',
+ errorText: 'Мы уже фиксим',
+ backLink: new Link({
+ to: routes.login,
+ label: 'Назад к чатам',
+ withId: true,
+ }),
+})
diff --git a/src/pages/loginPage/loginPage.css b/src/pages/loginPage/loginPage.css
new file mode 100644
index 000000000..f3249312f
--- /dev/null
+++ b/src/pages/loginPage/loginPage.css
@@ -0,0 +1,9 @@
+.login {
+ &__title {
+ margin-bottom: 40px;
+ }
+
+ .login-btn {
+ margin-top: 60px;
+ }
+}
diff --git a/src/pages/loginPage/loginPage.ts b/src/pages/loginPage/loginPage.ts
new file mode 100644
index 000000000..234274f1b
--- /dev/null
+++ b/src/pages/loginPage/loginPage.ts
@@ -0,0 +1,113 @@
+import Form from '@/components/form/form'
+import Input from '@/components/input/input'
+import Link from '@/components/link/link'
+import { routes } from '@/constants/routes'
+import { ValidationsMap } from '@/constants/validations'
+import { AuthController } from '@/controllers/AuthController.ts'
+import Block, { Props } from '@/core/Block'
+import router from '@/router.ts'
+import { RegisterData } from '@/services/AuthService.ts'
+import './loginPage.css'
+import connect from '@/utils/connect.ts'
+
+// language=hbs
+const loginPageTemplate = `
+
+
+
Вход
+ {{{ loginForm }}}
+
+ {{{ registerLink }}}
+
+
+`
+
+type LoginPageProps = {
+ loginForm: Form
+ registerLink: Link
+} & Props
+
+class LoginPage extends Block {
+ constructor(props: LoginPageProps) {
+ super(props)
+ }
+
+ componentDidMount() {
+ authController.getUser().then((resp) => {
+ if (resp.status === 200) {
+ router.go(routes.messenger)
+ }
+ })
+ }
+
+ render() {
+ return this.compile(loginPageTemplate, this.props)
+ }
+}
+
+const authController = new AuthController()
+
+const submitHandler = (e: Event) => {
+ e.preventDefault()
+ if (loginForm.getValues()) {
+ const values = loginForm.getValues() as RegisterData
+ authController.signin(values).then((resp) => {
+ if (resp instanceof XMLHttpRequest && resp.status === 200) {
+ router.go(routes.messenger)
+ } else {
+ loginForm.showInputError('login', 'Неверные данные')
+ loginForm.showInputError('password', 'Неверные данные')
+ }
+ })
+ }
+}
+
+const loginForm = new Form({
+ className: 'login-form dialog-form',
+ events: {
+ submit: submitHandler,
+ },
+ inputs: [
+ new Input({
+ type: 'text',
+ name: 'login',
+ label: 'Логин',
+ placeholder: 'Логин...',
+ validation: {
+ required: true,
+ regExp: ValidationsMap.login,
+ errorText: 'Неверный формат логина',
+ },
+ }),
+ new Input({
+ type: 'password',
+ name: 'password',
+ label: 'Пароль',
+ placeholder: 'Пароль...',
+ validation: {
+ required: true,
+ regExp: ValidationsMap.password,
+ errorText: 'Неверный формат пароля',
+ },
+ }),
+ ],
+ submitBtn: new Input({
+ type: 'submit',
+ name: 'submit',
+ label: '',
+ classNameInput: 'button input-submit',
+ value: 'Войти',
+ }),
+})
+
+const withUserdata = connect((state) => ({ userdata: state.userdata }))(
+ LoginPage
+)
+
+export const loginPage = new withUserdata({
+ loginForm: loginForm,
+ registerLink: new Link({
+ to: routes.register,
+ label: 'Нет аккаунта?',
+ }),
+})
diff --git a/src/pages/messengerPage/messengerPage.css b/src/pages/messengerPage/messengerPage.css
new file mode 100644
index 000000000..f1fb37661
--- /dev/null
+++ b/src/pages/messengerPage/messengerPage.css
@@ -0,0 +1,42 @@
+.messenger {
+ display: grid;
+ grid-template-columns: 310px 1fr;
+ height: 100%;
+
+ .sidebar {
+ position: relative;
+ height: 100%;
+ overflow-y: scroll;
+ background: var(--sidebar-bg);
+ border-right: 1px solid var(--border);
+ scrollbar-width: thin;
+ box-sizing: border-box;
+
+ &__top-block {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 22px;
+ padding: 14px 10px;
+
+ .back-btn {
+ color: var(--color-gray);
+ }
+ }
+
+ .create-chat-btn {
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ width: 40px;
+ height: 40px;
+ padding: 12px;
+ border-radius: 50%;
+ border: none;
+ background: var(--color-blue);
+ color: var(--color-white);
+ font-weight: 600;
+ cursor: pointer;
+ }
+ }
+}
diff --git a/src/pages/messengerPage/messengerPage.ts b/src/pages/messengerPage/messengerPage.ts
new file mode 100644
index 000000000..e9914591e
--- /dev/null
+++ b/src/pages/messengerPage/messengerPage.ts
@@ -0,0 +1,126 @@
+import Button from '@/components/button/button.ts'
+import ChatItem from '@/components/chatItem/chatItem.ts'
+import { ChatWindow, chatWindow } from '@/components/chatWindow/chatWindow.ts'
+import Input from '@/components/input/input.ts'
+import { Modal } from '@/components/modal/modal.ts'
+import { routes } from '@/constants/routes.ts'
+import { Chat } from '@/constants/types.ts'
+import { AuthController } from '@/controllers/AuthController.ts'
+import { ChatController } from '@/controllers/ChatController.ts'
+import Block, { Props } from '@/core/Block'
+import store from '@/core/Store.ts'
+import router from '@/router.ts'
+import connect from '@/utils/connect.ts'
+import './messengerPage.css'
+
+// language=hbs
+const MessengerPageTemplate = `
+
+
+
+ {{{ chat }}}
+
+`
+
+type MessengerPageProps = {
+ profileBtn: Button
+ chat: ChatWindow
+} & Props
+
+const authController = new AuthController()
+const chatController = new ChatController()
+
+export class MessengerPage extends Block {
+ private modal: Modal
+
+ constructor(props: MessengerPageProps) {
+ super(props)
+ this.modal = new Modal()
+ this.children.createChatBtn = new Button({
+ label: ' ',
+ className: 'create-chat-btn',
+ withId: true,
+ events: {
+ click: () => {
+ this.showCreateChatModal()
+ },
+ },
+ })
+ }
+
+ showCreateChatModal() {
+ const content = document.createElement('div')
+ const input = new Input({
+ type: 'text',
+ label: 'Название чата',
+ placeholder: 'Название...',
+ name: 'create-chat',
+ })
+ const btn = new Button({
+ label: 'Создать чат',
+ className: 'button input-submit',
+ events: {
+ click: () => {
+ chatController.createChat(input.getValue())
+ this.modal.close()
+ },
+ },
+ })
+
+ content.appendChild(input.element)
+ content.appendChild(btn.element)
+
+ this.modal.setContent('Создать чат', content)
+ this.modal.open()
+ }
+
+ createChatItems(chats: Chat[]) {
+ return chats.map((chat) => new ChatItem(chat))
+ }
+
+ componentDidMount() {
+ authController.getUser().then((resp) => {
+ if (resp.status === 401) {
+ router.go(routes.login)
+ } else {
+ chatController.getChats()
+ }
+ })
+ }
+
+ componentDidUpdate(oldProps?: Props, newProps?: Partial): boolean {
+ this.blockArrays.chats = this.createChatItems(store.getState().chats)
+ return super.componentDidUpdate(oldProps, newProps)
+ }
+
+ render() {
+ return this.compile(MessengerPageTemplate, this.props)
+ }
+}
+
+export const withChats = connect((state) => ({
+ chats: state.chats,
+}))(MessengerPage)
+
+export const messengerPage = new withChats({
+ profileBtn: new Button({
+ label: 'Профиль>',
+ className: 'button-icon back-btn',
+ events: {
+ click: () => {
+ router.go(routes.profile)
+ },
+ },
+ }),
+ chat: new chatWindow({
+ selectedChat: store.getState().selectedChat,
+ }),
+})
diff --git a/src/pages/profilePage/profilePage.css b/src/pages/profilePage/profilePage.css
new file mode 100644
index 000000000..8fd9cf703
--- /dev/null
+++ b/src/pages/profilePage/profilePage.css
@@ -0,0 +1,113 @@
+.profile {
+ display: grid;
+ grid-template-columns: 64px 1fr;
+ height: 100%;
+
+ .back {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background: var(--sidebar-bg);
+ border-right: 1px solid var(--border);
+
+ &__btn {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 28px;
+ height: 28px;
+ background: var(--color-blue);
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+
+ .lni {
+ color: var(--color-white);
+ }
+ }
+ }
+
+ &__avatar {
+ width: 130px;
+ height: 130px;
+ }
+
+ .profile-content {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ padding: 0 30%;
+
+ &__username {
+ padding: 20px 0 60px;
+ font-size: 18px;
+ font-weight: 600;
+ }
+
+ .profile-edit-form {
+ width: 100%;
+
+ &__save-btn {
+ margin-top: 20px;
+ }
+ }
+
+ .profile-info {
+ width: 100%;
+ padding-bottom: 60px;
+
+ .profile-info-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 10px 0;
+
+ &:not(:last-child) {
+ border-bottom: 1px solid var(--border);
+ }
+
+ &__name {
+ font-size: 13px;
+ font-weight: 600;
+ }
+
+ &__value {
+ color: var(--color-gray);
+ font-size: 13px;
+ }
+ }
+ }
+
+ .profile-actions {
+ display: flex;
+ flex-direction: column;
+ align-self: flex-start;
+ width: 100%;
+
+ &__wrapper {
+ padding: 10px 0;
+
+ &:not(:last-child) {
+ border-bottom: 1px solid var(--border);
+ }
+ }
+
+ &__link {
+ cursor: pointer;
+ font-weight: 500;
+ outline: none;
+ border: none;
+ background: none;
+ padding: 0;
+
+ &_blue {
+ color: var(--color-blue);
+ }
+
+ &_red {
+ color: var(--color-red);
+ }
+ }
+ }
+ }
+}
diff --git a/src/pages/profilePage/profilePage.ts b/src/pages/profilePage/profilePage.ts
new file mode 100644
index 000000000..f19fcb4e8
--- /dev/null
+++ b/src/pages/profilePage/profilePage.ts
@@ -0,0 +1,162 @@
+import Avatar from '@/components/avatar/avatar'
+import Button from '@/components/button/button.ts'
+import Link from '@/components/link/link'
+import { routes } from '@/constants/routes'
+import { User } from '@/constants/types'
+import { AuthController } from '@/controllers/AuthController.ts'
+import { UserController } from '@/controllers/UserController.ts'
+import Block from '@/core/Block'
+import store from '@/core/Store.ts'
+import router from '@/router.ts'
+import connect from '@/utils/connect.ts'
+import './profilePage.css'
+
+// language=hbs
+const ProfilePageTemplate = `
+
+
+ {{{ backBtn }}}
+
+
+
+
{{{ avatar }}}
+
+
{{ userdata.display_name }}
+
+
+
+ Почта
+ {{ userdata.email }}
+
+
+ Логин
+ {{ userdata.login }}
+
+
+ Имя
+ {{ userdata.first_name }}
+
+
+ Фамилия
+ {{ userdata.second_name }}
+
+
+ Имя в чате
+ {{ userdata.display_name }}
+
+
+ Телефон
+ {{ userdata.phone }}
+
+
+
+
+
+ {{{ editUserdataLink }}}
+
+
+ {{{ editPasswordLink }}}
+
+
+ {{{ logoutLink }}}
+
+
+
+
+`
+
+type ProfilePageProps = {
+ backBtn: Button
+ avatar: Avatar
+ userdata: User
+ editUserdataLink: Link
+ editPasswordLink: Link
+ logoutLink: Link
+}
+
+class ProfilePage extends Block {
+ constructor(props: ProfilePageProps) {
+ super(props)
+ }
+
+ componentDidMount() {
+ if (!store.getState().userdata) {
+ router.go(routes.login)
+ }
+ }
+
+ render() {
+ return this.compile(ProfilePageTemplate, this.props)
+ }
+}
+
+const authController = new AuthController()
+const userController = new UserController()
+
+const avatarUploadHandler = () => {
+ const fileInput = document.createElement('input')
+ fileInput.type = 'file'
+
+ fileInput.onchange = async (e: Event) => {
+ if (e.currentTarget instanceof HTMLInputElement && e.currentTarget.files) {
+ const formData = new FormData()
+ formData.append('avatar', e.currentTarget.files[0])
+ userController.editAvatar(formData)
+ }
+ }
+
+ fileInput.click()
+}
+
+const logoutBtnHandler = () => {
+ authController.logout().then((resp) => {
+ if (resp.status === 200) {
+ router.go(routes.login)
+ }
+ })
+}
+
+const withUserdata = connect((state) => ({ userdata: state.userdata }))(
+ ProfilePage
+)
+export const withUserAvatar = connect((state) => ({
+ src: state.userdata.avatar,
+}))(Avatar)
+
+export const profilePage = new withUserdata({
+ userdata: store.getState().userdata,
+ backBtn: new Button({
+ label: ' ',
+ className: 'back__btn',
+ events: {
+ click: () => {
+ router.go(routes.messenger)
+ },
+ },
+ }),
+ avatar: new withUserAvatar({
+ src: store.getState().userdata.avatar,
+ alt: 'avatar',
+ canChange: true,
+ events: {
+ click: avatarUploadHandler,
+ },
+ }),
+ editUserdataLink: new Link({
+ to: routes.editUserdata,
+ label: 'Изменить данные',
+ className: 'profile-actions__link profile-actions__link_blue',
+ }),
+ editPasswordLink: new Link({
+ to: routes.editPassword,
+ label: 'Изменить пароль',
+ className: 'profile-actions__link profile-actions__link_blue',
+ }),
+ logoutLink: new Button({
+ events: {
+ click: logoutBtnHandler,
+ },
+ label: 'Выйти',
+ className: 'link profile-actions__link profile-actions__link_red',
+ }),
+})
diff --git a/src/pages/registerPage/registerPage.css b/src/pages/registerPage/registerPage.css
new file mode 100644
index 000000000..7d7c4c19f
--- /dev/null
+++ b/src/pages/registerPage/registerPage.css
@@ -0,0 +1,5 @@
+.register {
+ &__title {
+ margin-bottom: 40px;
+ }
+}
diff --git a/src/pages/registerPage/registerPage.ts b/src/pages/registerPage/registerPage.ts
new file mode 100644
index 000000000..8446364d4
--- /dev/null
+++ b/src/pages/registerPage/registerPage.ts
@@ -0,0 +1,160 @@
+import Form from '@/components/form/form'
+import Input from '@/components/input/input'
+import Link from '@/components/link/link'
+import { routes } from '@/constants/routes'
+import { ValidationsMap } from '@/constants/validations'
+import { AuthController } from '@/controllers/AuthController.ts'
+import Block from '@/core/Block'
+import router from '@/router.ts'
+import { RegisterData } from '@/services/AuthService.ts'
+import './registerPage.css'
+import connect from '@/utils/connect.ts'
+
+// language=hbs
+const registerPageTemplate = `
+
+
+
Регистрация
+ {{{ registerForm }}}
+
+ {{{ loginLink }}}
+
+
+`
+
+type RegisterPageProps = {
+ registerForm: Form
+ loginLink: Link
+}
+
+class RegisterPage extends Block {
+ constructor(props: RegisterPageProps) {
+ super(props)
+ }
+
+ render() {
+ return this.compile(registerPageTemplate, this.props)
+ }
+}
+
+const authController = new AuthController()
+
+const submitHandler = (e: Event) => {
+ e.preventDefault()
+ if (registerForm.getValues()) {
+ const values = registerForm.getValues() as RegisterData
+ authController.signup(values).then((resp) => {
+ if (resp.status === 200) {
+ router.go(routes.messenger)
+ } else if (resp.status === 409) {
+ registerForm.showInputError('login', 'Логин уже занят')
+ }
+ })
+ }
+}
+
+const registerForm = new Form({
+ className: 'register-form dialog-form',
+ events: {
+ submit: submitHandler,
+ },
+ inputs: [
+ new Input({
+ type: 'text',
+ name: 'email',
+ label: 'Почта',
+ placeholder: 'Почта...',
+ validation: {
+ required: true,
+ regExp: ValidationsMap.email,
+ errorText: 'Неверный формат почты',
+ },
+ }),
+ new Input({
+ type: 'text',
+ name: 'login',
+ label: 'Логин',
+ placeholder: 'Логин...',
+ validation: {
+ required: true,
+ regExp: ValidationsMap.login,
+ errorText: 'Неверный формат логина',
+ },
+ }),
+ new Input({
+ type: 'text',
+ name: 'first_name',
+ label: 'Имя',
+ placeholder: 'Имя...',
+ validation: {
+ required: true,
+ regExp: ValidationsMap.name,
+ errorText: 'Неверный формат имени',
+ },
+ }),
+ new Input({
+ type: 'text',
+ name: 'second_name',
+ label: 'Фамилия',
+ placeholder: 'Фамилия...',
+ validation: {
+ required: true,
+ regExp: ValidationsMap.name,
+ errorText: 'Неверный формат фамилии',
+ },
+ }),
+ new Input({
+ type: 'text',
+ name: 'phone',
+ label: 'Телефон',
+ placeholder: 'Телефон...',
+ validation: {
+ required: true,
+ regExp: ValidationsMap.phone,
+ errorText: 'Неверный формат телефона',
+ },
+ }),
+ new Input({
+ type: 'password',
+ name: 'password',
+ label: 'Пароль',
+ placeholder: 'Пароль...',
+ validation: {
+ required: true,
+ regExp: ValidationsMap.password,
+ errorText: 'Неверный формат пароля',
+ },
+ }),
+ new Input({
+ type: 'password',
+ name: 'password_verify',
+ label: 'Пароль ещё раз',
+ placeholder: 'Повторите пароль...',
+ validation: {
+ required: true,
+ regExp: ValidationsMap.password,
+ errorText: 'Неверный формат пароля',
+ },
+ }),
+ ],
+ submitBtn: new Input({
+ type: 'submit',
+ name: 'submit',
+ label: '',
+ classNameInput: 'button input-submit',
+ value: 'Зарегистрироваться',
+ }),
+})
+
+const withUserdata = connect((state) => ({ userdata: state.userdata }))(
+ RegisterPage
+)
+
+export const registerPage = new withUserdata({
+ registerForm: registerForm,
+ loginLink: new Link({
+ to: routes.login,
+ label: 'Войти',
+ withId: true,
+ }),
+})
diff --git a/src/router.ts b/src/router.ts
new file mode 100644
index 000000000..73d22f83e
--- /dev/null
+++ b/src/router.ts
@@ -0,0 +1,5 @@
+import Router from '@/core/Router.ts'
+
+const router = new Router('#app')
+
+export default router
diff --git a/src/server.js b/src/server.js
new file mode 100644
index 000000000..12ea781bd
--- /dev/null
+++ b/src/server.js
@@ -0,0 +1,18 @@
+import express from 'express'
+import path from 'path'
+import { fileURLToPath } from 'url'
+
+const app = express()
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
+app.use(express.static(path.join(__dirname, '../dist')))
+
+app.get('/*', function (req, res) {
+ res.sendFile(path.join(__dirname, '../dist/index.html'))
+})
+
+app.listen(3000, () => {
+ console.log('Server started at port 3000')
+})
diff --git a/src/services/AuthService.ts b/src/services/AuthService.ts
new file mode 100644
index 000000000..426d2b282
--- /dev/null
+++ b/src/services/AuthService.ts
@@ -0,0 +1,40 @@
+import { BASE_URL } from '@/constants/api.ts'
+import HTTPTransport from '@/core/HTTPTransport.ts'
+
+export type RegisterData = {
+ first_name: string
+ second_name: string
+ login: string
+ email: string
+ password: string
+ phone: string
+}
+
+export type LoginData = {
+ login: string
+ password: string
+}
+
+export class AuthService {
+ authURL: string = `${BASE_URL}/auth`
+
+ signin(data: LoginData) {
+ return HTTPTransport.post(`${this.authURL}/signin`, {
+ body: data,
+ })
+ }
+
+ signup(data: RegisterData) {
+ return HTTPTransport.post(`${this.authURL}/signup`, {
+ body: data,
+ })
+ }
+
+ getUser() {
+ return HTTPTransport.get(`${this.authURL}/user`, {})
+ }
+
+ logout() {
+ return HTTPTransport.post(`${this.authURL}/logout`, {})
+ }
+}
diff --git a/src/services/ChatService.ts b/src/services/ChatService.ts
new file mode 100644
index 000000000..c37fdf4e7
--- /dev/null
+++ b/src/services/ChatService.ts
@@ -0,0 +1,53 @@
+import { BASE_URL } from '@/constants/api.ts'
+import HTTPTransport from '@/core/HTTPTransport.ts'
+
+export type ChatUsersRequest = {
+ users: number[]
+ chatId: number
+}
+
+export class ChatService {
+ chatsURL: string = `${BASE_URL}/chats`
+
+ getChats() {
+ return HTTPTransport.get(`${this.chatsURL}`, {})
+ }
+
+ getChatUsers(chatId: number) {
+ return HTTPTransport.get(`${this.chatsURL}/${chatId}/users`, {})
+ }
+
+ getToken(chatId: number) {
+ return HTTPTransport.post(`${this.chatsURL}/token/${chatId}`, {})
+ }
+
+ createChat(title: string) {
+ return HTTPTransport.post(`${this.chatsURL}`, {
+ body: { title },
+ })
+ }
+
+ deleteChat(chatId: number) {
+ return HTTPTransport.delete(`${this.chatsURL}`, {
+ body: { chatId },
+ })
+ }
+
+ uploadChatAvatar(data: FormData) {
+ return HTTPTransport.put(`${this.chatsURL}/avatar`, {
+ body: data,
+ })
+ }
+
+ addUserToChat(data: ChatUsersRequest) {
+ return HTTPTransport.put(`${this.chatsURL}/users`, {
+ body: data,
+ })
+ }
+
+ deleteUserFromChat(data: ChatUsersRequest) {
+ return HTTPTransport.delete(`${this.chatsURL}/users`, {
+ body: data,
+ })
+ }
+}
diff --git a/src/services/UserService.ts b/src/services/UserService.ts
new file mode 100644
index 000000000..7b6dcf976
--- /dev/null
+++ b/src/services/UserService.ts
@@ -0,0 +1,31 @@
+import { BASE_URL } from '@/constants/api.ts'
+import { EditPasswordData, User } from '@/constants/types.ts'
+import HTTPTransport from '@/core/HTTPTransport.ts'
+
+export class UserService {
+ userURL: string = `${BASE_URL}/user`
+
+ editProfile(data: Partial) {
+ return HTTPTransport.put(`${this.userURL}/profile`, {
+ body: data,
+ })
+ }
+
+ editAvatar(data: FormData) {
+ return HTTPTransport.put(`${this.userURL}/profile/avatar`, {
+ body: data,
+ })
+ }
+
+ editPassword(data: EditPasswordData) {
+ return HTTPTransport.put(`${this.userURL}/password`, {
+ body: data,
+ })
+ }
+
+ searchUser(login: string) {
+ return HTTPTransport.post(`${this.userURL}/search`, {
+ body: { login },
+ })
+ }
+}
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 000000000..c1c5e02ae
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,3 @@
+@import url('https://meyerweb.com/eric/tools/css/reset/reset.css');
+@import url('styles/variables.css');
+@import url('styles/base.css');
diff --git a/src/styles/base.css b/src/styles/base.css
new file mode 100644
index 000000000..cc36b9e85
--- /dev/null
+++ b/src/styles/base.css
@@ -0,0 +1,85 @@
+* {
+ font-family: var(--font-family);
+ color: var(--color-text);
+}
+
+html {
+ height: 100%;
+}
+
+body {
+ height: 100%;
+}
+
+main {
+ height: 100%;
+}
+
+h1 {
+ padding-bottom: 20px;
+ font-size: 40px;
+ font-weight: 500;
+}
+
+h2 {
+ font-size: 24px;
+ font-weight: 500;
+}
+
+h4 {
+ font-size: 20px;
+ font-weight: 400;
+}
+
+#app {
+ height: 100%;
+}
+
+.dialog {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+}
+
+.dialog-wrapper {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ min-width: 320px;
+ padding: 50px 30px 30px;
+ box-shadow: var(--box-shadow);
+ border-radius: var(--border-radius);
+}
+
+.dialog-form {
+ width: 100%;
+ padding-bottom: 16px;
+}
+
+.modal-content {
+ .users {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ .user {
+ display: flex;
+ align-items: center;
+
+ &__login {
+ flex-grow: 1;
+ }
+
+ &__delete-btn {
+ margin: 0;
+ border: none;
+ background: none;
+ cursor: pointer;
+ font-size: 20px;
+ line-height: 1;
+ }
+ }
+ }
+}
diff --git a/src/styles/variables.css b/src/styles/variables.css
new file mode 100644
index 000000000..13c952476
--- /dev/null
+++ b/src/styles/variables.css
@@ -0,0 +1,19 @@
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
+
+:root {
+ --color1: #efefef;
+ --color2: #e4edfd;
+ --color-gray: #999;
+ --color-blue: #3369f3;
+ --color-red: #ff2f2f;
+ --color-white: #fff;
+ --color-text: #1e1e1e;
+ --sidebar-bg: #fbfbfb;
+ --message-bg: #f8f8f8;
+ --blue-bg: #e4edfd;
+ --hover-bg: #ccc8;
+ --border: #eaeaea;
+ --box-shadow: 0 0 3px 3px var(--hover-bg);
+ --border-radius: 8px;
+ --font-family: 'Inter', sans-serif;
+}
diff --git a/src/utils/connect.ts b/src/utils/connect.ts
new file mode 100644
index 000000000..3cbdafb1b
--- /dev/null
+++ b/src/utils/connect.ts
@@ -0,0 +1,27 @@
+import Block, { PropsAndChildren } from '@/core/Block.ts'
+import store, { StateType, StoreEvents } from '@/core/Store.ts'
+import { isEqual } from '@/utils/index.ts'
+
+export default function connect(
+ mapStateToProps: (state: StateType) => P
+) {
+ return function (Component: T): T {
+ // @ts-expect-error mixin
+ return class extends Component {
+ constructor(args: PropsAndChildren) {
+ let state = mapStateToProps(store.getState())
+ super(args)
+
+ store.on(StoreEvents.UPDATED, () => {
+ const newState = mapStateToProps(store.getState())
+
+ if (!isEqual(state, newState)) {
+ this.setProps({ ...newState })
+ }
+
+ state = newState
+ })
+ }
+ }
+ }
+}
diff --git a/src/utils/connectToMessageSocket.ts b/src/utils/connectToMessageSocket.ts
new file mode 100644
index 000000000..c471688ab
--- /dev/null
+++ b/src/utils/connectToMessageSocket.ts
@@ -0,0 +1,66 @@
+import { MessageItemProps } from '@/components/message/message.ts'
+import { ChatController } from '@/controllers/ChatController.ts'
+import store from '@/core/Store.ts'
+
+const chatController = new ChatController()
+
+export default async (userId: number, chatId: number) => {
+ let token = ''
+
+ await chatController.getToken(chatId).then((resp) => {
+ token = resp.token
+ })
+
+ const socket = new WebSocket(
+ `wss://ya-praktikum.tech/ws/chats/${userId}/${chatId}/${token}`
+ )
+
+ socket.addEventListener('open', () => {
+ console.log(`Соединение установлено c чатом ${chatId}`)
+
+ if (socket) {
+ socket.send(
+ JSON.stringify({
+ content: '0',
+ type: 'get old',
+ })
+ )
+ }
+ })
+
+ const ping = setInterval(() => {
+ socket?.send(JSON.stringify({ type: 'ping' }))
+ }, 10000)
+
+ socket.addEventListener('message', (event) => {
+ try {
+ const data: MessageItemProps = JSON.parse(event.data)
+
+ if (Array.isArray(data)) {
+ store.set('messages', data)
+ }
+ if (data.type === 'message') {
+ const messages = store.getState().messages
+
+ data.time = new Date().toISOString()
+
+ store.set('messages', [data, ...messages])
+ } else if (data.type === 'user connected') {
+ const messages = store.getState().messages
+
+ data.time = new Date().toISOString()
+ data.content = `Юзер с id: ${data.content} присоединился к чату`
+
+ store.set('messages', [data, ...messages])
+ }
+ } catch (error) {
+ throw new Error('Невалидные данные сообщений')
+ }
+ })
+
+ socket.addEventListener('close', () => {
+ clearInterval(ping)
+ })
+
+ return socket
+}
diff --git a/src/utils/formatMessageDate.ts b/src/utils/formatMessageDate.ts
new file mode 100644
index 000000000..5e0a34ab3
--- /dev/null
+++ b/src/utils/formatMessageDate.ts
@@ -0,0 +1,26 @@
+export default (date: string) => {
+ const millisecondsInHour = 3600000
+ const now = Math.floor(new Date().getTime() / millisecondsInHour)
+ const messageDate = Math.floor(new Date(date).getTime() / millisecondsInHour)
+ const offset = now - messageDate
+
+ let options: Intl.DateTimeFormatOptions
+
+ if (offset < 24) {
+ options = {
+ hour: 'numeric',
+ minute: 'numeric',
+ }
+ } else if (offset < 168) {
+ options = {
+ weekday: 'short',
+ }
+ } else {
+ options = {
+ month: 'short',
+ day: 'numeric',
+ }
+ }
+
+ return new Intl.DateTimeFormat('ru-RU', options).format(new Date(date))
+}
diff --git a/src/utils/getResourceURL.ts b/src/utils/getResourceURL.ts
new file mode 100644
index 000000000..e66686ba8
--- /dev/null
+++ b/src/utils/getResourceURL.ts
@@ -0,0 +1,4 @@
+export default (resourceURL: string) => {
+ const url = resourceURL.replaceAll('/', '%2F')
+ return `https://ya-praktikum.tech/api/v2/resources/${url}`
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
new file mode 100644
index 000000000..971ad8660
--- /dev/null
+++ b/src/utils/index.ts
@@ -0,0 +1,2 @@
+export { default as renderDOM } from './renderDOM'
+export { default as isEqual } from './isEqual'
diff --git a/src/utils/isEqual.ts b/src/utils/isEqual.ts
new file mode 100644
index 000000000..74f7fb3ee
--- /dev/null
+++ b/src/utils/isEqual.ts
@@ -0,0 +1,35 @@
+export default function isEqual(obj1: object, obj2: object): boolean {
+ if (obj1 === obj2) {
+ return true
+ }
+
+ if (obj1 === null || obj2 === null) {
+ return obj1 === obj2
+ }
+
+ const keys1 = Object.keys(obj1)
+ const keys2 = Object.keys(obj2)
+
+ if (keys1.length !== keys2.length) {
+ return false
+ }
+
+ for (const key of keys1) {
+ if (!keys2.includes(key)) {
+ return false
+ }
+
+ const value1 = obj1[key as keyof typeof obj1]
+ const value2 = obj2[key as keyof typeof obj2]
+
+ if (typeof value1 === 'object' && typeof value2 === 'object') {
+ if (!isEqual(value1, value2)) {
+ return false
+ }
+ } else if (value1 !== value2) {
+ return false
+ }
+ }
+
+ return true
+}
diff --git a/src/utils/renderDOM.ts b/src/utils/renderDOM.ts
new file mode 100644
index 000000000..0f16697a6
--- /dev/null
+++ b/src/utils/renderDOM.ts
@@ -0,0 +1,13 @@
+import Block from '../core/Block'
+
+export default function render(query: string, block: Block) {
+ const root = document.querySelector(query)
+
+ if (!root) {
+ throw new Error('Нет рут элемента')
+ }
+
+ root.innerHTML = ''
+
+ root.appendChild(block.element)
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 000000000..9452e9bbf
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ },
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ESNext", "DOM", "es6"],
+ "moduleResolution": "Node",
+ "strict": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "noEmit": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitAny": true,
+ "noImplicitReturns": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strictNullChecks": true,
+ "allowImportingTsExtensions": true
+ },
+ "include": ["src", "types/pug.d.ts"],
+ "exclude": ["node_modules", "build", "static"]
+}
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 000000000..6de0d954f
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,22 @@
+import path from 'path'
+import { defineConfig } from 'vite'
+import eslint from 'vite-plugin-eslint'
+import stylelint from 'vite-plugin-stylelint'
+
+export default defineConfig({
+ root: './src',
+ build: {
+ outDir: '../dist',
+ },
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ },
+ },
+ server: {
+ host: true,
+ port: 3000,
+ open: true,
+ },
+ plugins: [eslint(), stylelint()],
+})