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 = ` +
+ {{alt}} + {{#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 = `` + +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}} +
+ {{{ avatar }}} + + {{ title }} + +
+ +
+ {{{ addUserBtn }}} + {{{ deleteUserBtn }}} + {{{ deleteChatBtn }}} +
+
+
+ + +
+ {{{ 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 = ` +
+ {{{ inputs }}} + {{{ submitBtn }}} +
+` + +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 = ` + +` + +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 = ` + + ` + + 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 = ` + +` + +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()], +})