diff --git a/README.md b/README.md index 7aacd01..19de08b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ - Typescript 5.5.3 - Реализован компонентный подход - Eslint & Stylelint +- Поключён WebSocket +- Внедрен роутинг +- Добавлен HTTP API для чатов --- diff --git a/package-lock.json b/package-lock.json index 4b069a4..1a2d468 100644 --- a/package-lock.json +++ b/package-lock.json @@ -571,16 +571,90 @@ } }, "@typescript-eslint/parser": { - "version": "7.16.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.16.1.tgz", - "integrity": "sha512-u+1Qx86jfGQ5i4JjK33/FnawZRpsLxRnKzGE6EABZ40KxVT/vWsiZFEBBHjFOljmmV3MBYOHEKi0Jm9hbAOClA==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "7.16.1", - "@typescript-eslint/types": "7.16.1", - "@typescript-eslint/typescript-estree": "7.16.1", - "@typescript-eslint/visitor-keys": "7.16.1", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4" + }, + "dependencies": { + "@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "requires": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + } + }, + "@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "requires": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + } + }, + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true + }, + "minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true + } } }, "@typescript-eslint/scope-manager": { diff --git a/package.json b/package.json index 46874d8..89687a9 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "devDependencies": { "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.16.1", - "@typescript-eslint/parser": "^7.16.1", + "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.57.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-import": "^2.29.1", diff --git a/src/api/AuthApi.ts b/src/api/AuthApi.ts new file mode 100644 index 0000000..4bce18e --- /dev/null +++ b/src/api/AuthApi.ts @@ -0,0 +1,33 @@ +import HTTPTransport, { TOptionsData } from "../core/HTTPTransport"; + +const auth = new HTTPTransport('/auth'); + +class AuthApi { + public createUser(data: TOptionsData): Promise { + return auth.post('/signup', { + data, + headers: { + 'Content-type': 'application/json; charset=UTF-8', + } + }); + } + + public login(data: TOptionsData): Promise { + return auth.post('/signin', { + data, + headers: { + 'Content-type': 'application/json; charset=UTF-8', + } + }); + } + + public getUser(): Promise { + return auth.get('/user'); + } + + public logout(): Promise { + return auth.post('/logout'); + } +} + +export default new AuthApi(); diff --git a/src/api/ChatApi.ts b/src/api/ChatApi.ts new file mode 100644 index 0000000..6ab2569 --- /dev/null +++ b/src/api/ChatApi.ts @@ -0,0 +1,65 @@ +import HTTPTransport from '../core/HTTPTransport'; +import { Chat, User } from '../utils/types'; + +const chats = new HTTPTransport('/chats'); + +class ChatAPI { + public async getChats(): Promise { + return chats.get('/'); + } + + public async createChat(title: string): Promise { + return chats.post('/', {data: { title }}); + } + + public async getUserToken(chatId: number): Promise<{ token: string }> { + const response = await chats.post(`/token/${chatId}`); + if (response instanceof XMLHttpRequest) { + return response.response; + } + + return response; + } + + public async getChatUsers(chatId: number | undefined): Promise { + return chats.get(`/${chatId}/users`); + } + + public async addUsers(data: any): Promise { + return chats.put('/users', { + data: data, + headers: { + 'Content-type': 'application/json; charset=UTF-8', + }, + }); + } + + public async removeUsers(data: any): Promise { + return chats.delete('/users', { + data: data, + headers: { + 'Content-type': 'application/json; charset=UTF-8', + } + }); + } + + public async deleteChat(chatId: any): Promise { + return chats.delete('/', { + data: chatId, + headers: { + 'Content-type': 'application/json; charset=UTF-8', + } + }); + } + + public async uploadAvatar(data: any): Promise { + return chats.put('/avatar', { + data: data, + headers: { + 'Content-Type': 'multipart/form-data' + } + }) + } +} + +export default new ChatAPI(); diff --git a/src/api/UserApi.ts b/src/api/UserApi.ts new file mode 100644 index 0000000..c341447 --- /dev/null +++ b/src/api/UserApi.ts @@ -0,0 +1,38 @@ +import HTTPTransport, {TOptionsData} from "../core/HTTPTransport"; + +const user = new HTTPTransport('/user'); + +class UserApi { + public changeData(data: TOptionsData): Promise { + return user.put('/profile', { + data, + headers: { + 'Content-type': 'application/json; charset=UTF-8', + } + }); + } + + public changePassword(data: TOptionsData): Promise { + return user.put('/password', { + data, + headers: { + 'Content-type': 'application/json; charset=UTF-8', + } + }); + } + + public changeAvatar(data: FormData): Promise { + return user.put('/profile/avatar', { data }); + } + + public searchUsers(login: string): Promise { + return user.post('/search', { + data: { login }, + headers: { + 'Content-type': 'application/json; charset=UTF-8', + } + }); + } +} + +export default new UserApi(); diff --git a/src/components/add-user-modal/user-modal.hbs b/src/components/add-user-modal/user-modal.hbs new file mode 100644 index 0000000..43caa6e --- /dev/null +++ b/src/components/add-user-modal/user-modal.hbs @@ -0,0 +1,16 @@ +{{#if isAddUserOpen }} +
+

{{#if isUserSearchEnabled}}Add user to chat{{else}}Delete user from chat{{/if}}

+ {{#if isUserSearchEnabled}} + {{{ Search users=users class="search" placeholder="Search users" }}} + {{/if}} + {{#if usersList }} + {{#each currentChatUsers }} +
    +
  • {{login}}
  • +
+ {{/each}} + {{/if}} + {{{ Button class="cancel" type="button" label="Cancel" onClick=onClose }}} +
+{{/if}} diff --git a/src/components/add-user-modal/user-modal.less b/src/components/add-user-modal/user-modal.less new file mode 100644 index 0000000..d078e30 --- /dev/null +++ b/src/components/add-user-modal/user-modal.less @@ -0,0 +1,69 @@ +@import '../../utils.less'; + +.user-modal { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + border-radius: 14px; + z-index: 1000; + background: @main__background-color; + padding: 3rem 2rem 1rem 2rem; + display: flex; + width: 30%; + flex-direction: column; + justify-content: space-between; + + .title { + text-align: center; + margin: 0 0 1rem 0; + } + + .user-item { + list-style: none; + border-bottom: 1px solid @secondary__background-color; + cursor: pointer; + + &:hover { + color: @main__background-hover-color; + } + } + + .search { + .search-results { + display: block; + background: @secondary__background-color; + position: relative; + list-style: none; + width: auto; + max-height: 15rem; + overflow-y: scroll; + border-radius: 5px; + margin-top: 0; + padding-inline-start: 15px; + padding-inline-end: 15px; + padding-bottom: 0.75rem; + + .user-item { + display: flex; + flex-direction: column; + border-bottom: 1px solid @main__background-color; + padding: 5px; + } + } + } + + .cancel { + .button( + @margin: 3rem 0 0 0, + @width: auto, + @background: none, + @border: none, + @color: @secondary__button-background-color + ); + + &:hover { + .custom-button-hover(@color: @main__button-background-hover); + } + } +} diff --git a/src/components/add-user-modal/user-modal.ts b/src/components/add-user-modal/user-modal.ts new file mode 100644 index 0000000..35e73ae --- /dev/null +++ b/src/components/add-user-modal/user-modal.ts @@ -0,0 +1,64 @@ +import './user-modal.less'; +import UserModalTmpl from './user-modal.hbs?raw'; +import Block, {Props} from '../../core/Block'; +import { Search } from '../search/search'; +import ChatController from "../../controllers/ChatController"; +import Store from '../../core/Store'; +import { connect } from '../../utils/connect'; +import {User} from "../../utils/types"; + +export class UserModalBase extends Block { + constructor(props: Props) { + super({ + ...props, + onClose: () => { + Store.set({isAddUserOpen: false}); + }, + events: { + click: (event: Event) => { + this.handleUserClick(event); + } + } + }); + } + + init() { + this.children.Search = new Search({} as any); + } + + private async handleUserClick(e: Event) { + const target = e.target as HTMLElement; + const userItem = target.closest('.user-item'); + if (userItem) { + const userId = userItem.getAttribute('data-user-id') || userItem.id; + if (userId) { + let user; + if (this.props.isUserSearchEnabled) { + user = this.props.users.find((u: User) => u.id.toString() === userId); + await ChatController.addUsers(user); + Store.set({isAddUserOpen: false}); + } else { + user = this.props.currentChatUsers.find((u: User) => u.id.toString() === userId); + await ChatController.removeUsers(user); + Store.set({isAddUserOpen: false}); + } + } + } + } + + render(): string { + return UserModalTmpl; + } +} + +export const UserModal = connect((state) => { + return { + isAddUserOpen: state?.isAddUserOpen || false, + isUserSearchEnabled: state?.isUserSearchEnabled || false, + selectedUser: state.selectedUser, + selectedChatId: state.selectedChat?.id, + currentChatUsers: state?.currentChatUsers || [], + users: state?.users || [], + usersList: state.usersList + } +})(UserModalBase); diff --git a/src/components/avatar/avatar.hbs b/src/components/avatar/avatar.hbs new file mode 100644 index 0000000..c1f714c --- /dev/null +++ b/src/components/avatar/avatar.hbs @@ -0,0 +1,7 @@ +
+ avatar {{name}} +
diff --git a/src/components/avatar/avatar.ts b/src/components/avatar/avatar.ts new file mode 100644 index 0000000..c806aa8 --- /dev/null +++ b/src/components/avatar/avatar.ts @@ -0,0 +1,17 @@ +import AvatarTmpl from './avatar.hbs?raw'; +import Block, {Props} from '../../core/Block'; + +export class Avatar extends Block { + constructor(props: Props) { + super({ + ...props, + events: { + ...(props.onClick ? { click: props.onClick } : {}), + }, + }); + } + + render(): string { + return AvatarTmpl; + } +} diff --git a/src/components/button/button.hbs b/src/components/button/button.hbs index 66c4e6c..3e8df5c 100644 --- a/src/components/button/button.hbs +++ b/src/components/button/button.hbs @@ -1 +1 @@ - + diff --git a/src/components/chat-body/chat-body.hbs b/src/components/chat-body/chat-body.hbs new file mode 100644 index 0000000..0faecbc --- /dev/null +++ b/src/components/chat-body/chat-body.hbs @@ -0,0 +1,52 @@ +{{#if selectedChat}} +
+
+
+
+ {{#if selectedChat.avatar}} + {{{ Avatar avatar=selectedChat.avatar name=selectedChat.login }}} + {{else}} +
+ {{firstLetter selectedChat.title}} +
+ {{/if}} +
+

{{selectedChat.title}}

+ {{#if isSettingsModalOpen }} + {{{ ChatSettingsModal selectedChat=selectedChat currentChatUsers=currentChatUsers }}} +
+ {{/if}} +
+
+ {{{ MenuButton isOpenChatMenu=isOpenChatMenu class="chat-menu-button" onClick=toggleMenu }}} + {{#if isOpenChatMenu}} + {{{ ChatMenu }}} + {{/if}} + {{#if isAddUserOpen }} + {{{ UserModal }}} +
+ {{/if}} +
+
+
+ {{#if messages.length}} + {{#each messages}} +
+

{{content}}

+ {{formatDate time}} +
+ {{/each}} + {{else}} +

No messages yet

+ {{/if}} +
+
+ + +
+
+{{else}} +
+

Select a chat to start messaging

+
+{{/if}} diff --git a/src/components/chat-body/chat-body.less b/src/components/chat-body/chat-body.less new file mode 100644 index 0000000..0cfe2c1 --- /dev/null +++ b/src/components/chat-body/chat-body.less @@ -0,0 +1,213 @@ +@import '../../utils.less'; + +.chat { + &.messenger { + width: 100%; + height: 100%; + display: flex; + + .messenger-body { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + + .chat-body { + display: flex; + flex-direction: column; + height: 100%; + + .chat-header { + padding: 2px; + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + background-color: @main__background-color; + border-bottom: 1px solid @secondary__background-color; + + .left-section { + display: flex; + flex-direction: row; + padding-inline-start: 2rem; + justify-content: space-between; + align-items: center; + width: auto; + border-right: 1px solid @secondary__background-color; + + .avatar { + display: flex; + align-items: center; + justify-content: center; + width: 1.375rem !important; + height: 1.375rem !important; + z-index: 100; + + .default-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 1.375rem !important; + height: 1.375rem !important; + z-index: 100; + + .title { + position: absolute; + } + + &:after { + content: ''; + border: 30px solid #343A4F; + border-radius: 100%; + } + } + + .image { + position: absolute; + + &.uploaded { + width: 3.675rem !important; + height: 3.675rem !important; + + img { + width: 3.675rem !important; + height: 3.675rem !important; + border-radius: 50%; + } + } + } + } + + .chat-title { + padding-right: 0.5rem; + cursor: pointer; + margin-left: 3rem; + + &:hover { + color: @main__button-background-hover; + + &:after { + color: @main__button-background-hover; + } + } + + &:after { + font-family: @icon-font; + content: @icon__angle-right; + color: @main__font-color; + vertical-align: middle; + font-size: 12px; + margin-left: 0.25rem; + } + } + } + } + + .chat-messages { + flex-grow: 1; + overflow-y: scroll; + padding: 10px; + text-align: center; + display: flex; + flex-direction: column; + + .message { + margin-bottom: 10px; + padding: 5px 10px; + max-width: 50%; + display: flex; + flex-direction: row; + justify-content: space-between; + + &.out { + align-self: flex-end; + background-color: @main__background-color; + border-radius: 12px 12px 0 12px; + } + + &.in { + align-self: flex-start; + background-color: @messenger__out_message_background_color; + border-radius: 12px 12px 12px 0; + } + + .content { + margin: 0 0.75rem 0 0; + } + + .time { + display: flex; + font-size: 10px; + color: #888; + align-self: flex-end; + } + } + } + + .chat-input { + border-top: 1px solid @secondary__background-color; + border-left: 1px solid @secondary__background-color; + display: flex; + position: relative; + align-items: center; + + .sender { + .input(@border-radius: 0, @width: 100%, @padding: 0, @color: @main__font-color); + + flex-grow: 1; + padding: 10px; + + &::placeholder { + color: @main__font-color; + font-size: 14px; + opacity: 1; + } + + &::-ms-input-placeholder { + color: @main__font-color; + font-size: 14px; + } + + &:focus-visible { + border: none; + outline: none; + } + } + + .send-message { + .button(@background: none, @margin: auto 1.5rem, @padding: 0, @width: 0); + + position: absolute; + right: 0; + border: 20px solid @main__button-background-color; + border-radius: 100%; + + &:before { + font-family: @icon-font; + content: '\e901'; + color: @main__icon-color; + font-size: 30px; + position: absolute; + transform: translate(-50%, -50%); + } + + &:hover { + .custom-button-hover(); + border-color: @main__button-background-hover; + } + } + } + } + + .no-chat-selected { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + font-size: 1.2em; + color: #888; + } + } + } +} diff --git a/src/components/chat-body/chat-body.ts b/src/components/chat-body/chat-body.ts new file mode 100644 index 0000000..e90cf2c --- /dev/null +++ b/src/components/chat-body/chat-body.ts @@ -0,0 +1,83 @@ +import './chat-body.less'; +import Block from '../../core/Block'; +import ChatBodyTemplate from './chat-body.hbs?raw'; +import { connect } from '../../utils/connect'; +import { Chat, MessageProps } from '../../utils/types'; +import { MessageController } from "../../controllers/MessageController"; +import Store from '../../core/Store'; + +const messageController = new MessageController(); + +interface ChatBodyProps { + selectedChat: Chat | null; + messages: MessageProps[]; + websocketError: string | null; +} + +class ChatBodyBase extends Block { + constructor(props: ChatBodyProps) { + super({ + ...props, + events: { + submit: (event: Event) => this.onSubmit(event), + click: (event: Event) => this.handleClick(event) + }, + toggleMenu: (event: Event) => { + event.stopPropagation(); + const isOpen = Store.getState().isOpenChatMenu; + Store.set({isOpenChatMenu: !isOpen}); + }, + openSettingsModal: (event: Event) => { + if (!event) return; + event.preventDefault(); + Store.set({isSettingsModalOpen: true}); + } + }); + } + + private closeMenu() { + Store.set({isOpenChatMenu: false}); + } + + private handleClick(e: Event) { + const target = e.target as HTMLElement; + const isMenuOpen = Store.getState().isOpenChatMenu; + + if (target.closest('.chat-menu-button')) { + this.props.toggleMenu(e); + } else if (isMenuOpen && !target.closest('.menu__wrapper') && !target.closest('form')) { + this.closeMenu(); + } + + if (target.closest('.chat-title')) { + this.props.openSettingsModal(e); + } + } + + private onSubmit(e: Event) { + e.preventDefault(); + const input = this._element?.querySelector('input[type="text"]') as HTMLInputElement; + const message = input.value.trim(); + if (message) { + messageController.sendMessage(message); + input.value = ''; + } + } + + render(): string { + return ChatBodyTemplate; + } +} + +export const ChatBody = connect((state) => { + return { + selectedChat: state.selectedChat, + currentUserId: state.user?.id, + messages: state.currentChatMessages || [], + websocketError: state.websocketError, + isOpenChatMenu: state.isOpenChatMenu || false, + isAddUserOpen: state.isAddUserOpen || false, + isSettingsModalOpen: state.isSettingsModalOpen || false, + currentChatUsers: state.currentChatUsers || [], + }; +})(ChatBodyBase); diff --git a/src/components/chat-create/chat-create.hbs b/src/components/chat-create/chat-create.hbs new file mode 100644 index 0000000..5b0933d --- /dev/null +++ b/src/components/chat-create/chat-create.hbs @@ -0,0 +1,16 @@ +
+ +
diff --git a/src/components/chat-create/chat-create.less b/src/components/chat-create/chat-create.less new file mode 100644 index 0000000..1e3a622 --- /dev/null +++ b/src/components/chat-create/chat-create.less @@ -0,0 +1,54 @@ +@import '../../utils.less'; + +#app { + .chat { + .create-chat-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + justify-content: center; + align-items: center; + z-index: 1000; + + &.is-open { + display: flex; + } + + .modal-content { + background-color: @main__background-color; + width: 25%; + padding: 2rem 3rem; + border-radius: 14px; + text-align: center; + + .create-chat-form { + display: flex; + flex-direction: column; + margin: 2rem 1rem; + + .chat-title { + .input(@background: @secondary__background-color); + margin-bottom: 2.5rem; + } + + .chat-create-button { + margin: 0 auto; + } + } + + .close-modal { + margin-top: 1rem; + .button(@background: none, @width: auto, @border: none); + + &:hover { + .custom-button-hover(@color: @main__button-background-color); + } + } + } + } + } +} diff --git a/src/components/chat-create/chat-create.ts b/src/components/chat-create/chat-create.ts new file mode 100644 index 0000000..8ef27e0 --- /dev/null +++ b/src/components/chat-create/chat-create.ts @@ -0,0 +1,75 @@ +import './chat-create.less'; +import Block from '../../core/Block'; +import CreateChatFormTemplate from './chat-create.hbs?raw'; +import ChatController from '../../controllers/ChatController'; +import Store from '../../core/Store'; +import {connect} from "../../utils/connect"; + +interface CreateChatFormProps { + isOpen: boolean; + error: string | null; + success: string | null; +} + +export class ChatCreateBase extends Block { + constructor(props: CreateChatFormProps) { + super({ + ...props, + events: { + submit: (e: Event) => this.onSubmit(e), + click: (e: Event) => this.onClick(e), + }, + closeModal: () => { + Store.set({isCreateChatModalOpen: false}); + } + }); + } + + private onClick(e: Event) { + const target = e.target as HTMLElement; + if (target.classList.contains('create-chat-modal') || target.classList.contains('close-modal')) { + e.preventDefault(); + this.closeModal(); + } + } + + private closeModal() { + Store.set({isCreateChatModalOpen: false}); + } + + private async onSubmit(e: Event) { + e.preventDefault(); + const form = e.target as HTMLFormElement; + const formData = new FormData(form); + const chatTitle = formData.get('chatTitle') as string; + + if (chatTitle) { + try { + await ChatController.createChat(chatTitle); + form.reset(); + Store.set({createChatSuccess: 'Chat created successfully', createChatError: null }); + setTimeout(() => { + this.closeModal(); + Store.set({createChatSuccess: null}); + }, 2000); + } catch (error) { + console.error('Error creating chat:', error); + Store.set({createChatError: 'Failed to create chat', createChatSuccess: null}); + } + } else { + Store.set({createChatError: 'Failed to create chat', createChatSuccess: null}); + } + } + + render(): string { + return CreateChatFormTemplate; + } +} + +export const ChatCreate = connect((state) => { + return { + isOpen: state.isCreateChatModalOpen, + error: state.createChatError, + success: state.createChatSuccess + }; +})(ChatCreateBase); diff --git a/src/components/chat-item/chat-item.hbs b/src/components/chat-item/chat-item.hbs new file mode 100644 index 0000000..9aca15d --- /dev/null +++ b/src/components/chat-item/chat-item.hbs @@ -0,0 +1,25 @@ +
+
+ {{#if chat.avatar}} +
+ {{{ Avatar avatar=chat.avatar name=chat.login }}} +
+ {{else}} +
+ {{firstLetter chat.title}} +
+ {{/if}} +
+
+
{{chat.title}}
+
{{#if chat.last_message}}{{formatDate chat.last_message.time}}{{/if}}
+
+
+
{{#if chat.last_message}}{{chat.last_message.content}}{{/if}}
+
+ {{#if chat.unread_count}} + {{chat.unread_count}} + {{/if}} +
+
+
diff --git a/src/components/chat-item/chat-item.ts b/src/components/chat-item/chat-item.ts new file mode 100644 index 0000000..b15ac75 --- /dev/null +++ b/src/components/chat-item/chat-item.ts @@ -0,0 +1,29 @@ +import ChatItemTmpl from './chat-item.hbs?raw' +import Block, { Props } from '../../core/Block'; +import isEqual from "../../utils/isEqual"; + +export class ChatItem extends Block { + constructor(props: Props) { + super({ + ...props, + select: () => (props?.chat.id === props?.currentChat), + events: { + click: (e: Event) => { + if (!e) return; + e.preventDefault(); + if (props.onSetCurrentChat) { + props.onSetCurrentChat.call(this, this.props?.chat.id); + } + }, + }, + }); + } + + protected componentDidUpdate(oldProps: Props, newProps: Props): boolean { + return !isEqual(oldProps, newProps); + } + + render(): string { + return ChatItemTmpl; + } +} diff --git a/src/components/chat-list/chat-list.hbs b/src/components/chat-list/chat-list.hbs new file mode 100644 index 0000000..dca26e8 --- /dev/null +++ b/src/components/chat-list/chat-list.hbs @@ -0,0 +1,9 @@ +
+ {{#if chats.length}} + {{#each chats}} + {{{ ChatItem chat=this onSetCurrentChat=../onSetCurrentChat currentChat= ../currentChat }}} + {{else}} +

No chats available

+ {{/each}} + {{/if}} +
diff --git a/src/components/chat-list/chat-list.less b/src/components/chat-list/chat-list.less new file mode 100644 index 0000000..291b6d1 --- /dev/null +++ b/src/components/chat-list/chat-list.less @@ -0,0 +1,183 @@ +@import '../../utils.less'; + +.sidebar { + flex: 1; + + .list { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + + .list-header { + display: flex; + flex-direction: column; + height: 5rem; + border-right: 1px solid @secondary__background-color; + border-bottom: 1px solid @secondary__background-color; + + .section { + &.buttons { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + .create-new-chat { + margin: 0; + + .new-chat { + .button(@margin: 0, @background: none, @border: none, @width: auto); + + &:hover { + .custom-button-hover(); + } + } + } + + .profile { + align-self: flex-end; + padding: 0.25rem 0.1rem; + + .profile-button { + .button(@background: none, @width: 100%, @margin: 0); + + &:hover { + background: none !important; + color: @main__button-background-hover; + + &:after { + color: @main__button-background-hover; + } + } + + &:after { + font-family: @icon-font; + content: @icon__angle-right; + color: @main__font-color; + vertical-align: middle; + font-size: 12px; + margin-left: 0.25rem; + } + } + + &:hover, &:active, &:focus-visible { + background: none !important; + color: @main__button-background-hover !important; + } + } + } + } + + } + + .chat-list { + overflow-y: scroll; + } + + .item { + display: flex; + flex-direction: column; + justify-content: center; + padding-top: 0 !important; + padding-bottom: 0 !important; + padding-inline: 1rem .75rem; + border-bottom: 1px solid @secondary__background-color; + min-height: 4.5rem; + padding-inline-start: 4.5rem !important; + text-decoration: none; + color: @main__font-color; + position: relative; + cursor: pointer; + + &.selected { + background: @main__button-background-color; + + .dialog { + &.subtitle { + color: @main__font-color; + } + } + } + + &:hover { + background: @main__background-hover-color; + } + + .avatar { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + inset-inline-start: .5625rem !important; + + .loaded-avatar { + inset-inline-start: .5625rem !important; + width: 3.575rem !important; + height: 3.575rem !important; + + .image { + &.uploaded { + img { + width: 3.575rem !important; + height: 3.575rem !important; + border-radius: 50%; + } + } + } + } + + .default-avatar { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + inset-inline-start: .1rem !important; + width: 3.375rem !important; + height: 3.375rem !important; + + .title { + position: absolute; + font-size: 25px; + } + + &:after { + content: ''; + border: 30px solid @secondary__background-color; + border-radius: 100%; + } + } + } + + .dialog { + display: flex; + justify-content: space-between; + align-items: center; + + &.title { + height: 1.375rem; + font-weight: bold; + } + + &.subtitle { + height: 1.375rem; + margin-top: .125rem; + color: @secondary__font-color; + + .unread-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + background-color: @secondary__font-color; /* Вы можете изменить цвет на любой подходящий вашему дизайну */ + color: white; + border-radius: 10px; /* Это создаст круглую форму */ + font-size: 12px; + font-weight: bold; + } + } + } + } + } +} diff --git a/src/components/chat-list/chat-list.ts b/src/components/chat-list/chat-list.ts new file mode 100644 index 0000000..ebca84d --- /dev/null +++ b/src/components/chat-list/chat-list.ts @@ -0,0 +1,28 @@ +import './chat-list.less'; +import Block, {Props} from '../../core/Block'; +import ChatListTemplate from './chat-list.hbs?raw'; +import ChatController from '../../controllers/ChatController'; +import { connect } from '../../utils/connect'; + +export class ChatListBase extends Block { + constructor(props: Props) { + super({ + ...props, + onSetCurrentChat: (id: number | undefined) => { + ChatController.setCurrentChat(id); + }, + }); + } + + render(): string { + return ChatListTemplate; + } +} + +export const ChatList = connect((state) => { + return { + chats: state.chats || [], + currentChat: state.currentChat, + selectedChat: state.selectedChat, + }; +})(ChatListBase); diff --git a/src/components/chat-menu/chat-menu.hbs b/src/components/chat-menu/chat-menu.hbs new file mode 100644 index 0000000..c7eda34 --- /dev/null +++ b/src/components/chat-menu/chat-menu.hbs @@ -0,0 +1,15 @@ +{{#if isOpenChatMenu }} +
+
    +
  • + {{{ Button class="add-user-button" label="Add User" onClick=addUser }}} +
  • +
  • + {{{ Button class="delete-user-button" label="Delete User" onClick=deleteUser }}} +
  • +
  • + {{{ Button class="delete-chat-button" label="Delete Chat" onClick=deleteChat }}} +
  • +
+
+{{/if}} diff --git a/src/components/chat-menu/chat-menu.less b/src/components/chat-menu/chat-menu.less new file mode 100644 index 0000000..b9f3148 --- /dev/null +++ b/src/components/chat-menu/chat-menu.less @@ -0,0 +1,31 @@ +@import '../../utils.less'; + +.chat-header { + .chat-menu { + .actions-list { + position: absolute; + right: 0; + background-color: @main__background-color; + padding: 20px; + z-index: 1000; + border-radius: 14px; + margin-top: 0; + box-shadow: 0px 0px 0px 2px @main__background-color_darker, + 0px 2px 4px @main__background-color_darker, + 0px 4px 8px @main__background-color_darker, + 0px 8px 16px @main__background-color_darker; + + .action-item { + list-style: none; + + .button { + .button(@margin: 0, @background: none, @width: auto); + + &:hover { + .custom-button-hover(); + } + } + } + } + } +} diff --git a/src/components/chat-menu/chat-menu.ts b/src/components/chat-menu/chat-menu.ts new file mode 100644 index 0000000..c4fae7f --- /dev/null +++ b/src/components/chat-menu/chat-menu.ts @@ -0,0 +1,38 @@ +import './chat-menu.less'; +import ChatMenuTmpl from './chat-menu.hbs?raw'; +import Block, {Props} from "../../core/Block"; +import Store from "../../core/Store"; +import {connect} from "../../utils/connect"; +import ChatController from "../../controllers/ChatController"; + +export class ChatMenuBase extends Block { + constructor(props: Props) { + super({ + ...props, + addUser: () => { + Store.set({isAddUserOpen: true, isUserSearchEnabled: true, usersList: false, isOpenChatMenu: false}); + }, + deleteUser: () => { + Store.set({isAddUserOpen: true, isUserSearchEnabled: false, usersList: true, isOpenChatMenu: false}); + }, + deleteChat: () => { + Store.set({isOpenChatMenu: false, selectedChat: false}); + ChatController.deleteChat(); + } + }); + } + + render(): string { + return ChatMenuTmpl; + } +} + +export const ChatMenu = connect((state) => { + return { + isOpenChatMenu: state.isOpenChatMenu, + selectedChatId: state.selectedChat?.id, + isAddUserOpen: state.isAddUserOpen || false, + isUserSearchEnabled: state.isUserSearchEnabled || false, + usersList: state.usersList || false + }; +})(ChatMenuBase); diff --git a/src/components/chat-settings-modal/chat-settings-modal.hbs b/src/components/chat-settings-modal/chat-settings-modal.hbs new file mode 100644 index 0000000..81f991e --- /dev/null +++ b/src/components/chat-settings-modal/chat-settings-modal.hbs @@ -0,0 +1,30 @@ +{{#if isSettingsModalOpen}} +
+
+ {{#if selectedChat.avatar}} +
+ {{{ Avatar avatar=selectedChat.avatar name=selectedChat.login }}} + {{{ Input class="avatar-uploader" style="display: none" id="avatar-uploader" type="file" accept="image/*" skipValidation=true onChange=changeChatAvatar }}} +
+ {{else}} +
+ {{firstLetter selectedChat.title}} + {{{ Input class="avatar-uploader" style="display: none" id="avatar-uploader" type="file" accept="image/*" skipValidation=true onChange=changeChatAvatar }}} +
+ {{/if}} +
+
+
    + {{#each currentChatUsers }} +
  • + {{first_name}} {{second_name}} + +
  • + {{/each}} +
+
+
+ {{{ Button class="close" type="button" label="Cancel" onClick=onClose }}} +
+
+{{/if}} diff --git a/src/components/chat-settings-modal/chat-settings-modal.less b/src/components/chat-settings-modal/chat-settings-modal.less new file mode 100644 index 0000000..72d551f --- /dev/null +++ b/src/components/chat-settings-modal/chat-settings-modal.less @@ -0,0 +1,131 @@ +@import '../../utils.less'; + +.chat.messenger { + .messenger-body { + .chat-body { + .chat-header { + .chat-settings { + display: flex; + flex-direction: column; + z-index: 1000; + top: 15%; + position: absolute; + background: white; + width: 25%; + padding: 3rem 2rem; + align-items: center; + border-radius: 14px; + background: @main__background-color; + + .avatar { + margin-bottom: 2rem; + display: flex; + align-items: center; + justify-content: center; + position: relative; + width: 4.995rem !important; + height: 4.995rem !important; + + &:hover { + cursor: pointer; + background: rgba(0, 0, 0, 0.5); + border-radius: 50%; + width: 6.675rem !important; + height: 6.675rem !important; + + &:before { + content: 'Change avatar' !important; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 15px; + z-index: 1001; + } + + &:after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + } + } + + .loaded-avatar-modal { + position: absolute; + width: 6.575rem !important; + height: 6.575rem !important; + + .image { + width: 6.575rem !important; + height: 6.575rem !important; + cursor: pointer; + + &.uploaded { + width: 6.575rem !important; + height: 6.575rem !important; + + img { + width: 6.575rem !important; + height: 6.575rem !important; + } + } + } + } + + .default-avatar { + position: absolute; + font-size: 25px; + + &:after { + content: ''; + border: 53px solid @secondary__background-color; + border-radius: 100%; + } + } + } + + .chat-users { + margin-bottom: 2rem; + width: 100%; + + .users-list { + display: flex; + flex-direction: column; + padding: 0; + text-align: center; + + .user-item { + display: flex; + flex-direction: column; + border-bottom: 1px solid @secondary__background-color; + padding: 5px; + cursor: pointer; + + &:hover { + background: @main__background-hover-color; + } + } + } + } + + .close-action { + .close { + .button(@margin: 0, @width: auto, @background: none, @color: @secondary__button-background-color); + + &:hover { + .custom-button-hover(@color: @secondary__button-background-color); + opacity: 70%; + } + } + } + } + } + } + } +} diff --git a/src/components/chat-settings-modal/chat-settings-modal.ts b/src/components/chat-settings-modal/chat-settings-modal.ts new file mode 100644 index 0000000..b8195f5 --- /dev/null +++ b/src/components/chat-settings-modal/chat-settings-modal.ts @@ -0,0 +1,79 @@ +import './chat-settings-modal.less'; +import ChatSettingsModalTmpl from './chat-settings-modal.hbs?raw'; +import Block, {Props} from '../../core/Block'; +import Store from '../../core/Store'; +import {connect} from "../../utils/connect"; +import ChatController from "../../controllers/ChatController"; + +export class ChatSettingsModalBase extends Block { + constructor(props: Props) { + super({ + ...props, + events: { + click: (event: Event) => { + this.handleClick(event); + } + }, + onClose: () => { + Store.set({isSettingsModalOpen: false}); + }, + changeChatAvatar: (event: Event) => { + if (!event) return; + event.preventDefault(); + + const input = event?.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + const file = input.files[0]; + this.uploadAvatar(file); + } + } + }); + } + + private handleClick(event: Event) { + const target = event.target as HTMLElement; + const defaultAvatar = target.closest('.avatar'); + const avatarLoaded = target.closest('.image'); + + if (defaultAvatar) { + this.onClickAvatar(); + } + + if (avatarLoaded) { + this.onClickAvatar(); + } + } + + onClickAvatar() { + const avatarUploader: HTMLInputElement | undefined = this._element?.querySelector('#avatar-uploader') as HTMLInputElement | undefined; + if (avatarUploader) { + avatarUploader.click(); + } + } + + async uploadAvatar(file: File) { + try { + const data = new FormData(); + data.set('chatId', this.props.selectedChat.id); + data.set('avatar', file, file.name); + + const update = await ChatController.changeAvatar(data); + + if (update) { + this.setProps({selectedChat: update}); + } + } catch (error) { + console.error('AvatarUploader: Error uploading avatar:', error); + } + } + + render(): string { + return ChatSettingsModalTmpl; + } +} + +export const ChatSettingsModal = connect((state) => { + return { + isSettingsModalOpen: state.isSettingsModalOpen || false, + } +})(ChatSettingsModalBase); diff --git a/src/components/error/error.hbs b/src/components/error/error.hbs index b294b5b..a772db2 100644 --- a/src/components/error/error.hbs +++ b/src/components/error/error.hbs @@ -1,6 +1,6 @@

{{errorTitle}}

{{errorText}} - {{{ Button class="error back-button" label="Chat" page="ChatPage" }}} + {{{ Button class="error back-button" label="Chat" }}}
diff --git a/src/components/input/input.hbs b/src/components/input/input.hbs index 86538d7..13924c1 100644 --- a/src/components/input/input.hbs +++ b/src/components/input/input.hbs @@ -4,9 +4,12 @@ type="{{ type }}" name="{{ name }}" class="input {{ class }}" + id="{{ id }}" + style="{{ style }}" placeholder="{{ placeholder }}" value="{{ value }}" {{#if autocomplete}}autocomplete="{{ autocomplete }}"{{/if}} + {{#if readonly}}readonly{{/if}} />
{{ error }}
diff --git a/src/components/input/input.ts b/src/components/input/input.ts index 40e05bf..3889989 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -19,7 +19,21 @@ export class Input extends Block { input: (e: Event) => { const input = e.target as HTMLInputElement; this.props.value = input.value; - } + }, + change: (e: Event) => { + const input = e.target as HTMLInputElement; + this.setValue(input.value); + + if (!this.props.skipValidation) { + this.validate(); + } + + if (typeof this.props.onChange === 'function') { + this.props.onChange(e); + } + }, + + ...(props.onInput ? { input: props.onInput} : {}) } }); } @@ -33,6 +47,10 @@ export class Input extends Block { } public validate(): boolean { + if (this.props.skipValidation) { + return true; + } + const errorMessage: string | null = validateField(this.props.name, this.props.value); const hasError: boolean = !!errorMessage; this.setProps({ diff --git a/src/components/menu-button/menu-button.hbs b/src/components/menu-button/menu-button.hbs new file mode 100644 index 0000000..887a86f --- /dev/null +++ b/src/components/menu-button/menu-button.hbs @@ -0,0 +1,9 @@ + diff --git a/src/components/menu-button/menu-button.less b/src/components/menu-button/menu-button.less new file mode 100644 index 0000000..38f5fff --- /dev/null +++ b/src/components/menu-button/menu-button.less @@ -0,0 +1,75 @@ +@import "../../utils.less"; + +.menu__wrapper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding-inline-end: 2rem; + flex: 1; + > div { + width: 60px; + height: 60px; + padding: 20px; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + cursor: pointer; + &:hover, &:focus { + outline: none; + } + } + + &.chat-menu-button { + .button(@margin: 0, @background: none, @border: none); + } + + .circle { + width: 6px; + height: 6px; + margin: 3px; + background: #fff; + border-radius: 50%; + display: block; + } + + .menu__item--kebab { + flex-direction: column; + position: relative; + transition: all 300ms cubic-bezier(0.175, 0.885, 0.32, 1.275); + background: none; + border: none; + + .circle:nth-child(4), + .circle:nth-child(5) { + position: absolute; + opacity: 0; + top: 50%; + margin-top: -3px; + left: 50%; + } + .circle:nth-child(4) { + margin-left: -12px; + } + .circle:nth-child(5) { + margin-left: 6.5px; + } + &:hover, + &:focus, + &:active, + &:focus-visible { + transform: rotate(45deg); + .circle { + opacity: 1; + } + } + + &.active { + transform: rotate(45deg); + .circle { + opacity: 1; + } + } + } +} diff --git a/src/components/menu-button/menu-button.ts b/src/components/menu-button/menu-button.ts new file mode 100644 index 0000000..d9ab8db --- /dev/null +++ b/src/components/menu-button/menu-button.ts @@ -0,0 +1,16 @@ +import './menu-button.less'; +import MenuButtonTmpl from './menu-button.hbs?raw'; +import Block, {Props} from '../../core/Block'; + +export class MenuButton extends Block { + constructor(props: Props) { + super({...props}); + this.props.events = { + click: this.props.onClick || (() => {}), + }; + } + + render(): string { + return MenuButtonTmpl; + } +} diff --git a/src/components/search/search.hbs b/src/components/search/search.hbs new file mode 100644 index 0000000..5a956b4 --- /dev/null +++ b/src/components/search/search.hbs @@ -0,0 +1,26 @@ + diff --git a/src/components/search/search.less b/src/components/search/search.less new file mode 100644 index 0000000..5b3bba6 --- /dev/null +++ b/src/components/search/search.less @@ -0,0 +1,50 @@ +@import '../../utils.less'; + +.search { + display: block; + width: 95%; + margin: 0 auto; + + .search-element { + .input(@background: @secondary__background-color, @margin: 0 auto); + + outline: none; + + &:focus-visible, &:active { + border: 1px solid @main__button-background-color; + } + } + + .search-results { + display: block; + background: @secondary__background-color; + position: absolute; + z-index: 100; + list-style: none; + border-radius: 5px; + max-height: 70%; + width: 29.5%; + overflow-y: scroll; + margin-top: 0; + padding-inline-start: 15px; + padding-inline-end: 15px; + + .user-item { + display: flex; + flex-direction: column; + border-bottom: 1px solid @main__background-color; + padding: 5px; + } + + .chat-results { + padding: 0; + + .chat-item { + display: flex; + flex-direction: column; + border-bottom: 1px solid @main__background-color; + padding: 5px; + } + } + } +} diff --git a/src/components/search/search.ts b/src/components/search/search.ts new file mode 100644 index 0000000..25e7d2a --- /dev/null +++ b/src/components/search/search.ts @@ -0,0 +1,97 @@ +import './search.less'; +import Block, {Props} from '../../core/Block'; +import SearchTemplate from './search.hbs?raw'; +import { User, Chat } from '../../utils/types'; +import UserController from '../../controllers/UserController'; +import ChatController from '../../controllers/ChatController'; +import isEqual from "../../utils/isEqual"; + +interface SearchUsersProps { + onUserSelect: (user: User) => void; + onChatSelect?: (chat: Chat) => void; +} + +export class Search extends Block { + private searchTimeout: number | null = null; + + constructor(props: SearchUsersProps) { + super({ + ...props, + users: [], + chats: [], + searchQuery: '', + events: { + input: (e: Event) => { + const input = e.target as HTMLInputElement; + this.setProps({ searchQuery: input.value }); + this.debouncedSearch(); + }, + search: (e: Event) => { + e.preventDefault(); + this.search(); + }, + click: (e: Event) => { + this.handleClick(e); + } + } + }); + } + + protected componentDidUpdate(oldProps: Props, newProps: Props): boolean { + return isEqual(oldProps, newProps); + } + + private handleClick(e: Event) { + const target = e.target as HTMLElement; + const userItem = target.closest('.user-item'); + const chatItem = target.closest('.chat-item'); + + if (userItem) { + const userId = userItem.getAttribute('data-user-id'); + if (userId) { + const user = this.props.users.find((u: User) => u.id.toString() === userId); + if (user) { + // To do: add create chat with user + } + } + } + + if (chatItem) { + const chatId = chatItem.getAttribute('data-chat-id'); + if (chatId) { + const chat = this.props.chats.find((c: Chat) => c.id.toString() === chatId); + ChatController.setCurrentChat(chat.id); + } + } + } + + private debouncedSearch() { + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + this.searchTimeout = setTimeout(() => { + this.search(); + }, 1000) as unknown as number; + } + + private async search() { + const query: string = this.props.searchQuery as string; + if (query.length < 3) { + this.setProps({ users: [], chats: [] }); + return; + } + + try { + const users = await UserController.searchUsers(query); + const chats = await ChatController.searchChats(query); + this.setProps({ users, chats, error: '' }); + } catch (error) { + console.error('Error searching:', error); + this.setProps({ users: [], chats: [], error: 'Error searching' }); + } + } + + render() { + return SearchTemplate; + } +} diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts new file mode 100644 index 0000000..9adc278 --- /dev/null +++ b/src/controllers/AuthController.ts @@ -0,0 +1,63 @@ +import AuthApi from "../api/AuthApi"; +import {TOptionsData} from "../core/HTTPTransport"; +import { goToLogin, goToMessenger, goToError500 } from "../utils/router"; +import ChatController from "./ChatController"; +import Store from "../core/Store"; + +class AuthController { + public async createUser(data: TOptionsData): Promise { + try { + const { status, response } = await AuthApi.createUser(data); + + if (status === 200 && response) { + this.getUser(); + return true; + } else if (status === 500) { + goToError500(); + return false; + } else { + alert(JSON.parse(response).reason ?? 'Bad request'); + return false; + } + } catch (e) { + console.error(e); + return false; + } + } + + public async login(data: TOptionsData): Promise { + try { + await AuthApi.login(data); + const user = await this.getUser(); + const chats = await ChatController.getChats(); + Store.set({user: user, chats}); + goToMessenger(); + } catch (error) { + console.error(error); + } + } + + public async getUser(): Promise { + try { + const response = await AuthApi.getUser(); + + if (response) { + Store.set({user: response}); + return true; + } else { + return false; + } + } catch (e) { + console.error('Error in user:', e); + return false; + } + } + + public async logout(): Promise { + await AuthApi.logout(); + Store.set({user: null, currentChat: null}); + goToLogin(); + } +} + +export default new AuthController(); diff --git a/src/controllers/ChatController.ts b/src/controllers/ChatController.ts new file mode 100644 index 0000000..d371c41 --- /dev/null +++ b/src/controllers/ChatController.ts @@ -0,0 +1,120 @@ +import ChatAPI from '../api/ChatApi'; +import Store from '../core/Store'; +import {Chat, User} from '../utils/types'; +import { MessageController } from "./MessageController"; +const messageController = new MessageController(); + +class ChatController { + async getChats(): Promise { + try { + const chats = await ChatAPI.getChats(); + if (chats) { + return chats; + } else { + return []; + } + } catch (error) { + console.error('Error fetching chats:', error); + throw error; + } + } + + async createChat(title: string): Promise { + try { + const newChat = await ChatAPI.createChat(title); + const currentChats = Store.getState().chats || []; + Store.set({chats: [...currentChats, newChat]}); + return newChat; + } catch (error) { + console.error('Error creating chat:', error); + throw error; + } + } + + async searchChats(query: string): Promise { + try { + const allChats = await this.getChats(); + return allChats.filter(chat => + chat.title.toLowerCase().includes(query.toLowerCase()) + ); + } catch (error) { + console.error('Error searching chats:', error); + return []; + } + } + + public async getChatUsers(id: number | undefined): Promise { + try { + const chatUsers = await ChatAPI.getChatUsers(id); + Store.set({currentChatUsers: chatUsers}); + return chatUsers; + } catch (error) { + console.error('Error fetching chatUsers:', error); + } + } + + public async addUsers(user: User): Promise { + const { selectedChat, currentChatUsers } = Store.getState(); + if (user && selectedChat) { + try { + await ChatAPI.addUsers({ chatId: selectedChat.id, users: [user.id] }); + } catch (error) { + console.error(error); + } + + const newCurrentChatUsers = [...currentChatUsers, user]; + Store.set({currentChatUsers: newCurrentChatUsers}); + } + } + + async removeUsers(data: { id: number, users: number[] }): Promise { + const { selectedChat, currentChatUsers } = Store.getState(); + + if (data && selectedChat) { + try { + await ChatAPI.removeUsers({ users: [data.id], chatId: selectedChat.id }); + } catch (error) { + console.error(error); + } + + const newCurrentChatUsers = currentChatUsers.filter((user: { id: number; }) => user.id !== data.id); + Store.set({currentChatUsers: newCurrentChatUsers}); + } + } + + public async deleteChat(): Promise { + const { chats, currentChat } = Store.getState(); + if (currentChat) { + await ChatAPI.deleteChat({ chatId: currentChat }); + Store.set({ + chats: chats.filter((chat: { id: any; }) => (chat.id !== currentChat)), + currentChat: null + }) + } + } + + public async changeAvatar(data: FormData): Promise { + try { + const response = await ChatAPI.uploadAvatar(data); + if (response) { + Store.set({selectedChat: response}); + return response; + } + } catch (error) { + console.error('Error uploading avatar:', error); + } + } + + public async setCurrentChat(id: number | undefined) { + const chats = Store.getState().chats; + const selectedChat = chats.find((chat: Chat) => chat.id === id); + const chatUsers = await this.getChatUsers(id); + + Store.set({ currentChat: id, selectedChat: selectedChat, currentChatUsers: chatUsers }); + + messageController.disconnect(); + messageController.connect(); + } +} + +export default new ChatController(); diff --git a/src/controllers/MessageController.ts b/src/controllers/MessageController.ts new file mode 100644 index 0000000..199dc22 --- /dev/null +++ b/src/controllers/MessageController.ts @@ -0,0 +1,94 @@ +import { MessageProps } from '../utils/types'; +import Socket, { Message, WebSocketProps } from '../core/Socket'; +import ChatAPI from '../api/ChatApi'; +import Store from '../core/Store'; + +const setLastMessage = (message: MessageProps) => { + const { chats, currentChat, currentChatUsers } = Store.getState(); + if (chats && currentChat) { + const chat = chats.find((c: { id: any; }) => c.id === currentChat); + if (chat) { + const user = currentChatUsers.find((u: { id: any; }) => u.id === message.user_id); + const newChat = { ...chat }; + newChat.last_message = { + user: { + id: user?.id || 0, + login: user?.login || '', + first_name: user?.first_name || '', + second_name: user?.second_name || '', + display_name: user?.display_name || '', + avatar: user?.avatar || '', + role: user?.role || '', + }, + time: message.time, + content: message.content, + }; + Store.set({chats: chats.map((c: any) => (c === chat ? newChat : c))}) + } + } +}; + +export class MessageController { + static __instance: MessageController | undefined; + + protected socket: Socket | null = null; + + protected socketProps: WebSocketProps = { + userId: 0, + chatId: 0, + token: '', + callbackMessages: (data: MessageProps | MessageProps[]) => { + this.addMessage(data); + }, + }; + + constructor() { + if (MessageController.__instance) { + return MessageController.__instance; + } + + MessageController.__instance = this; + } + + async getUserToken(chatId: number) { + return await ChatAPI.getUserToken(chatId); + } + + async connect() { + const { user, currentChat } = Store.getState(); + if (user && currentChat) { + this.socketProps.userId = user.id; + this.socketProps.chatId = currentChat; + + const { token } = await this.getUserToken(currentChat); + + this.socketProps.token = token; + + this.socket = new Socket(this.socketProps); + } + } + + disconnect() { + this.socket?.closeConnect(); + } + + sendMessage(message: string) { + const mess: Message = { + content: message, + type: 'message', + }; + this.socket?.send(mess); + } + + addMessage(message: MessageProps | MessageProps[]) { + const { currentChatMessages } = Store.getState(); + let newChatMessages: MessageProps[] = []; + if (Array.isArray(message)) { + newChatMessages = [...message].reverse(); + } else { + newChatMessages = [...currentChatMessages, message]; + setLastMessage(message as any); + } + Store.set({currentChatMessages: newChatMessages}); + } +} diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts new file mode 100644 index 0000000..4c13665 --- /dev/null +++ b/src/controllers/UserController.ts @@ -0,0 +1,81 @@ +import UserApi from "../api/UserApi"; +import {goToError500} from "../utils/router"; +import Store from "../core/Store"; + +class UserController { + public async changeUserData(data: any): Promise { + try { + const response = await UserApi.changeData(data); + + if (response && typeof response === 'object') { + if (response instanceof XMLHttpRequest) { + const userData = response.response; + Store.set({user: userData}); + return userData; + } else { + Store.set({user: response}); + return response; + } + } else { + goToError500(); + } + } catch (e) { + console.error(e); + } + } + + public async changePassword(data: any): Promise { + try { + const response = await UserApi.changePassword(data); + + if (response && typeof response === 'object') { + if (response instanceof XMLHttpRequest) { + return response.response; + } else { + return response; + } + } else { + goToError500(); + } + } catch (e) { + console.error(e); + } + } + + public async changeAvatar(data: FormData) { + try { + const response = await UserApi.changeAvatar(data); + if (response && typeof response === 'object') { + if (response instanceof XMLHttpRequest) { + const userData = response.response; + Store.set({user: userData}); + return userData; + } else { + Store.set({user: response}); + return response; + } + } else { + goToError500(); + } + } catch (e) { + console.error(e); + } + } + + public async searchUsers(query: string): Promise { + try { + const response = await UserApi.searchUsers(query); + if (response) { + Store.set({users: response}); + return response; + } else { + return []; + } + } catch (error) { + console.error('Error searching users:', error); + throw error; + } + } +} + +export default new UserController(); diff --git a/src/core/Block.ts b/src/core/Block.ts index 35bfa49..b74264e 100644 --- a/src/core/Block.ts +++ b/src/core/Block.ts @@ -1,16 +1,22 @@ import { EventBus } from './EventBus'; import Handlebars from 'handlebars'; import { v4 as makeUUID } from 'uuid'; +import { Indexed } from "../utils/utils"; +import isEqual from "../utils/isEqual"; export type Events = Record void>; -export type Props = Record; +export type Props = Record; export type Children = Record; +export type BlockType = { + new(propsAndParent: Props): Block +}; class Block { static EVENTS: Record = { INIT: "init", FLOW_CDM: "flow:component-did-mount", FLOW_CDU: "flow:component-did-update", + FLOW_CWU: 'flow:component-will-unmount', FLOW_RENDER: "flow:render" } as const; @@ -69,6 +75,7 @@ class Block { 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)); } @@ -79,7 +86,6 @@ class Block { } public init() { - return this; } private _componentDidMount(): void { @@ -92,7 +98,8 @@ class Block { }); } - public componentDidMount(): void {} + public componentDidMount(): void { + } public dispatchComponentDidMount(): void { this.eventBus().emit(Block.EVENTS.FLOW_CDM); @@ -112,10 +119,16 @@ class Block { } protected componentDidUpdate(oldProps: Props, newProps: Props) { - return oldProps !== newProps; + return !isEqual(oldProps, newProps); + } + + protected _componentWillUnmount() { + this.componentWillUnmount(); } - public setProps = (nextProps: Props): void => { + public componentWillUnmount() {} + + public setProps = (nextProps: Props) => { if (!nextProps) { return; } @@ -123,20 +136,18 @@ class Block { const oldProps = { ...this.props }; Object.assign(this.props, nextProps); this.eventBus().emit(Block.EVENTS.FLOW_CDU, oldProps, this.props); - }; + } private _render() { const fragment = this._compile(); - const newElement = fragment.firstElementChild as HTMLElement; - if (this._element) { + if (this._element && this._element.parentNode) { this._removeEvents(); this._element.replaceWith(newElement); } this._element = newElement; - this._addEvents(); } @@ -144,26 +155,26 @@ class Block { const template = this.render(); const fragment = document.createElement('template'); - const context = { + if (!template) { + return document.createDocumentFragment(); + } + + fragment.innerHTML = Handlebars.compile(template)({ ...this.props, __children: this.children, - }; - - fragment.innerHTML = Handlebars.compile(template)(context); + }); Object.entries(this.children).forEach(([id, child]) => { const stub = fragment.content.querySelector(`[data-id="${id}"]`); if (!stub) { return; } - if (child instanceof Block) { const content = child.getContent(); - if (content) { - stub.replaceWith(content); + if (!content) { + return; } - } else if (child instanceof Element) { - stub.replaceWith(child); + stub.replaceWith(content); } }); @@ -191,9 +202,10 @@ class Block { return typeof value === "function" ? value.bind(target) : value; }, set(target: Props, prop: string, value: unknown){ + const oldTarget = { ...target }; target[prop] = value; - self.eventBus().emit(Block.EVENTS.FLOW_CDU, {...target}, target); + self.eventBus().emit(Block.EVENTS.FLOW_CDU, oldTarget, target); return true; }, deleteProperty() { @@ -205,6 +217,28 @@ class Block { private _createDocumentElement(tagName: string): HTMLElement { return document.createElement(tagName); } + + show() { + const content = this.getContent(); + if (content) { + content.style.display = ''; + } + } + + hide() { + const content = this.getContent(); + if (content) { + content.style.display = 'none'; + } + } + + static getStateToProps(_state: Indexed): Props { + return {}; + } + + public forceUpdate() { + this._render(); + } } export default Block; diff --git a/src/core/HTTPTransport.ts b/src/core/HTTPTransport.ts index 657c1ce..1730851 100644 --- a/src/core/HTTPTransport.ts +++ b/src/core/HTTPTransport.ts @@ -1,38 +1,47 @@ +import queryStringify from '../utils/queryStringify'; +import {HOST} from '../utils/hosts'; + enum METHODS { GET = 'GET', POST = 'POST', PUT = 'PUT', DELETE = 'DELETE', } - +export type TOptionsData = Record>; export type Options = { headers?: Record, method?: string, - data?: any, + data?: Record | FormData | TOptionsData, timeout?: number, } export type HTTP = (url: string, options?: Options) => Promise; -export default class HTTPTransport { +class HTTPTransport { + protected apiUrl: string = ''; + + constructor(apiPath: string) { + this.apiUrl = `${HOST}${apiPath}`; + } + get: HTTP = (url = '', options = {}) => { - return this.request(url, {...options, method: METHODS.GET}, options.timeout); + return this.request(`${this.apiUrl}${url}`, {...options, method: METHODS.GET}, options.timeout); }; post: HTTP = (url = '', options = {}) => { - return this.request(url, {...options, method: METHODS.POST}, options.timeout); + return this.request(`${this.apiUrl}${url}`, {...options, method: METHODS.POST}, options.timeout); }; put: HTTP = (url = '', options = {}) => { - return this.request(url, {...options, method: METHODS.PUT}, options.timeout); + return this.request(`${this.apiUrl}${url}`, {...options, method: METHODS.PUT}, options.timeout); }; delete: HTTP = (url = '', options = {}) => { - return this.request(url, {...options, method: METHODS.DELETE}, options.timeout); + return this.request(`${this.apiUrl}${url}`, {...options, method: METHODS.DELETE}, options.timeout); }; request = (url: string, options: Options = {}, timeout = 5000) => { - const {headers = {}, method, data} = options; + const {method, data} = options; return new Promise(function(resolve, reject) { if (!method) { @@ -41,45 +50,44 @@ export default class HTTPTransport { } const xhr = new XMLHttpRequest(); - const isGet = method === METHODS.GET; - xhr.open( - method, - isGet && !!data - ? `${url}${queryStringify(data)}` - : url, - ); + if (method === METHODS.GET && data) { + // eslint-disable-next-line no-param-reassign + url += queryStringify(data as Record); + } - Object.keys(headers).forEach(key => { - xhr.setRequestHeader(key, headers[key]); - }); + xhr.open(method || METHODS.GET, url); - xhr.onload = function() { - resolve(xhr); + if (data instanceof FormData) { + xhr.setRequestHeader('Accept', 'application/json'); + } else { + xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); + } + + xhr.onload = () => { + if (xhr.status !== 200) { + reject(new Error(`Error ${xhr.status}: ${xhr?.response?.reason || xhr.statusText}`)); + } else { + resolve(xhr.response); + } }; xhr.onabort = reject; xhr.onerror = reject; - xhr.timeout = timeout; xhr.ontimeout = reject; + xhr.responseType = 'json'; + xhr.withCredentials = true; - if (isGet || !data) { + if (method === METHODS.GET || !data) { xhr.send(); - } else { + } else if (data instanceof FormData) { xhr.send(data); + } else { + xhr.send(JSON.stringify(data)); } }); }; } -function queryStringify(data: any): string { - if (typeof data !== 'object') { - throw new Error('Data must be object'); - } - - const keys: string[] = Object.keys(data); - return keys.reduce((result: string, key: string, index: number) => { - return `${result}${key}=${data[key]}${index < keys.length - 1 ? '&' : ''}`; - }, '?'); -} +export default HTTPTransport; diff --git a/src/core/RegistrationComponent.ts b/src/core/RegistrationComponent.ts index 5bbbadd..f52be83 100644 --- a/src/core/RegistrationComponent.ts +++ b/src/core/RegistrationComponent.ts @@ -2,16 +2,21 @@ import Handlebars from 'handlebars'; import Block from "./Block"; export function registerComponent(name: string, Component: typeof Block) { - Handlebars.registerHelper(name, function(this: any, { hash, data }) { - const component: Block = new Component(hash); - const id: string = `${name}-${component.id}`; + if (!Handlebars.helpers[name]) { + Handlebars.registerHelper(name, function (this: any, {hash, data, fn}) { + const component: Block = new Component(hash); + const id: string = `${name}-${component.id}`; - if (hash.ref) { - (data.root.__refs = data.root.__refs || {})[hash.ref] = component; - } + if (hash.ref) { + (data.root.__refs = data.root.__refs || {})[hash.ref] = component; + } - (data.root.__children = data.root.__children || {})[id] = component; + (data.root.__children = data.root.__children || {})[id] = component; - return `
`; - }); + const contents = fn ? fn(this) : ''; + + + return `
${contents}
`; + }); + } } diff --git a/src/core/Render.ts b/src/core/Render.ts new file mode 100644 index 0000000..0e1ef2b --- /dev/null +++ b/src/core/Render.ts @@ -0,0 +1,19 @@ +import Block from './Block'; + +export function render(rootQuery: string, block: Block) { + const root = document.querySelector(rootQuery) as HTMLElement; + + if (root) { + root.innerHTML = ''; + const content = block.getContent(); + if (content) { + root.appendChild(content); + } + + block.dispatchComponentDidMount(); + + return root; + } + + return null; +} diff --git a/src/core/Route.ts b/src/core/Route.ts new file mode 100644 index 0000000..984427f --- /dev/null +++ b/src/core/Route.ts @@ -0,0 +1,44 @@ +import { render } from './Render'; +import Block, { Props, BlockType } from "./Block"; + +class Route { + protected _pathname: string; + protected _blockClass: BlockType; + protected _block: Block | null = null; + protected _props: Props; + + constructor(pathname: string, view: BlockType, props: Props) { + this._pathname = pathname; + this._blockClass = view; + this._block = null; + this._props = props; + } + + navigate(pathname: string) { + if (this.match(pathname)) { + this._pathname = pathname; + this.render(); + } + } + + leave() { + this._block = null; + } + + match(pathname: string) { + return pathname === this._pathname; + } + + render() { + if (!this._block) { + this._block = new this._blockClass({}); + render(this._props.rootQuery as string, this._block); + + return; + } + + this._block.show(); + } +} + +export default Route; diff --git a/src/core/Router.ts b/src/core/Router.ts new file mode 100644 index 0000000..509a3e1 --- /dev/null +++ b/src/core/Router.ts @@ -0,0 +1,83 @@ +import Route from './Route'; +import { BlockType } from './Block'; + +class Router { + static __instance: Router | undefined; + + protected _routes: Route[] = []; + protected _history: History = window.history; + protected _currentRoute: Route | null = null; + protected _rootQuery: string = ''; + + constructor(rootQuery: string) { + if (Router.__instance) { + return Router.__instance; + } + + this._rootQuery = rootQuery; + + Router.__instance = this; + + window.addEventListener('popstate', this._onPopState.bind(this)); + } + + use(pathname: string, block: BlockType) { + const route: Route = new Route(pathname, block, {rootQuery: this._rootQuery}); + this._routes.push(route); + + return this; + } + + start() { + window.onpopstate = (event) => { + const window = event.currentTarget as Window; + if (window) { + this._onRoute(window.location.pathname); + } + }; + + this._onRoute(window.location.pathname); + } + + private _onPopState = () => { + this._onRoute(window.location.pathname); + } + + private _onRoute(pathname: string) { + const route = this.getRoute(pathname); + + if (!route) { + return; + } + + if (this._currentRoute && this._currentRoute !== route) { + this._currentRoute.leave(); + } + + this._currentRoute = route; + route.render(); + } + + 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)); + } + + getCurrentRoute() { + return this._currentRoute; + } +} + +export default Router; diff --git a/src/core/Socket.ts b/src/core/Socket.ts new file mode 100644 index 0000000..642395b --- /dev/null +++ b/src/core/Socket.ts @@ -0,0 +1,99 @@ +export type WebSocketProps = { + userId: number; + chatId: number; + token: string; + callbackMessages: (data: any) => void; +}; + +export type Message = { + content: unknown; + type: string; +}; + +enum STATE { + CONNECTING, + OPEN, + CLOSING, + CLOSED, +} + +class Socket { + private socket: WebSocket; + protected _baseUrl: string; + protected _chatsUrl: string; + protected timeoutId: number = 0; + protected callbackMessages: (data: any) => void; + chatId: number; + + constructor({ + userId, chatId, token, callbackMessages, + }: WebSocketProps) { + this._baseUrl = 'wss://ya-praktikum.tech/ws'; + this._chatsUrl = `${this._baseUrl}/chats`; + this.chatId = chatId; + this.callbackMessages = callbackMessages; + this.socket = new WebSocket( + `${this._chatsUrl}/${userId}/${chatId}/${token}`, + ); + + this.socket.onerror = this.error; + this.socket.onclose = this.close.bind(this); + this.socket.onmessage = this.message.bind(this); + this.socket.onopen = this.open.bind(this); + + this.timeoutId = 0; + } + + public send(message: Message) { + this.socket.send(JSON.stringify(message)); + } + + public open(_event: Event) { + this.sendPing(); + this.socket.send( + JSON.stringify({ + content: '0', + type: 'get old', + }), + ); + } + + public close(event: CloseEvent) { + if (event.wasClean) { + console.log('Connection closed cleanly'); + } else { + console.log('Connection failure'); + } + + console.log(`Code: ${event.code} | Reason: ${event.reason}`); + } + + public message(event: MessageEvent) { + try { + const data = JSON.parse(event.data); + + if (data.type !== 'user connected' && data.type !== 'pong') { + this.callbackMessages(data); + } + } catch (error) { + console.error('Error receiving message', error); + } + } + + public error(event: Event) { + console.error('Error', event); + } + + public closeConnect() { + this.socket?.close(1000, 'The work is done'); + } + + protected sendPing() { + if (this.socket?.readyState === STATE.OPEN) { + this.send({ content: 'ping', type: 'ping' }); + this.timeoutId = setTimeout(this.sendPing.bind(this), 20000); + } + } +} + +export default Socket; diff --git a/src/core/Store.ts b/src/core/Store.ts new file mode 100644 index 0000000..10aa848 --- /dev/null +++ b/src/core/Store.ts @@ -0,0 +1,52 @@ +import { EventBus } from './EventBus'; +import isEqual from "../utils/isEqual"; +import { User } from '../utils/types'; + +export enum StoreEvents { + Updated = 'updated', +} + +interface StoreState { + notificationMessage?: string; + selectedUser?: User; + users?: User[]; + [key: string]: any; +} + +class Store extends EventBus { + private state: StoreState = {}; + + constructor() { + super(); + } + + public getState() { + return this.state; + } + + public set(nextState: Partial) { + const prevState = { ...this.state }; + const newState = { ...this.state, ...nextState }; + + if (!isEqual(prevState, newState)) { + this.state = newState; + this.emit(StoreEvents.Updated, prevState, newState); + } + } + + public setNotificationMessage(message: string) { + this.set({notificationMessage: message}); + } + + public emit(event: string, ...args: unknown[]) { + if (!this.listeners[event]) { + return; + } + + this.listeners[event].forEach(listener => { + listener(...args); + }); + } +} + +export default new Store(); diff --git a/src/main.ts b/src/main.ts index 75c0de5..0f3e07c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,12 @@ import Handlebars from 'handlebars'; import { registerComponent } from './core/RegistrationComponent'; +import {goToMessenger, goToLogin, router} from "./utils/router"; +import { BlockType } from "./core/Block"; import './style.less'; +import AuthController from './controllers/AuthController'; +import './utils/helpers'; +import Store from "./core/Store"; +import ChatController from "./controllers/ChatController"; type ImportValue = Record; type ImportGlob = Record; @@ -35,42 +41,39 @@ const registerImports = (imports: ImportValue) => { registerImports(components); registerImports(pages); -const navigator = (pageName: string) => { - const Page: any = pages[pageName]; - if (Page) { - const app = document.getElementById('app'); - if (app) { - if (typeof Page === 'function') { - const page = new Page({}); - const content = page.getContent(); - if (content) { - app.innerHTML = ''; - app.appendChild(content); - } +async function initApp() { + router + .use('/', pages.LoginPage as unknown as BlockType) + .use('/login', pages.LoginPage as unknown as BlockType) + .use('/signup', pages.RegistrationPage as unknown as BlockType) + .use('/messenger', pages.ChatPage as unknown as BlockType) + .use('/settings', pages.ProfilePage as unknown as BlockType) + .use('/settings/edit-password', pages.ProfileEditPasswordPage as unknown as BlockType) + .use('/404', pages.Error404Page as unknown as BlockType) + .use('/500', pages.Error500Page as unknown as BlockType) + .start(); + + try { + const user = await AuthController.getUser(); + if (user) { + const currentRoute = router.getCurrentRoute(); + if (currentRoute && ( + currentRoute.match('/')) + || currentRoute?.match('/login') + || currentRoute?.match('/signup') + ) { + goToMessenger(); } + } else { + goToLogin(); } + } catch (e) { + console.error('Error during app initialization:', e); + goToLogin(); } -}; - -document.addEventListener('DOMContentLoaded', () => { - navigator('MainPage'); -}); -document.addEventListener('click', (event) => { - const target: HTMLElement = event.target as HTMLElement; - const page = target.getAttribute('page'); - if (page) { - navigator(page); + const chats = await ChatController.getChats(); + Store.set({chats}); +} - event.preventDefault(); - event.stopImmediatePropagation(); - } -}); - -document.addEventListener('navigate', (event: Event) => { - const customEvent = event as CustomEvent; - const page = customEvent.detail.page; - if (page) { - navigator(page); - } -}); +document.addEventListener('DOMContentLoaded', () => initApp()); diff --git a/src/pages/chat/chat.hbs b/src/pages/chat/chat.hbs index 9745834..10e587d 100644 --- a/src/pages/chat/chat.hbs +++ b/src/pages/chat/chat.hbs @@ -1,52 +1,27 @@ -
+
- Select a chat to send a message + {{{ ChatBody currentChat=currentChat }}}
+ {{{ ChatCreate }}}
diff --git a/src/pages/chat/chat.less b/src/pages/chat/chat.less index 8d78f5c..c2cc101 100644 --- a/src/pages/chat/chat.less +++ b/src/pages/chat/chat.less @@ -1,142 +1,22 @@ @import '../../utils.less'; -.chat { - display: flex; +#app { + height: 100vh !important; + width: 100vw !important; - .sidebar { - flex: 1; - - .list { + .chat { + &.wrapper { display: flex; - flex-direction: column; width: 100%; + height: 100%; + } - .list-header { - display: flex; - flex-direction: column; - height: 5rem; - border-right: 1px solid @secondary__background-color; - border-bottom: 1px solid @secondary__background-color; - - .profile { - align-self: flex-end; - padding: 0.25rem 0.1rem; - - .profile-button { - .button(@background: none, @width: 100%, @margin: 0); - - &:hover { - background: none !important; - color: @main__button-background-hover; - - &:after { - color: @main__button-background-hover; - } - } - - &:after { - font-family: @icon-font; - content: @icon__angle-right; - color: @main__font-color; - vertical-align: middle; - font-size: 12px; - margin-left: 0.25rem; - } - } - - &:hover, &:active, &:focus-visible { - background: none !important; - color: @main__button-background-hover !important; - } - } - - .search { - display: block; - width: 95%; - margin: 0 auto; - - .chat-search { - .input(@background: @secondary__background-color, @margin: 0 auto); - } - } - } - - .item { - display: flex; - flex-direction: column; - justify-content: center; - padding-top: 0 !important; - padding-bottom: 0 !important; - padding-inline: 1rem .75rem; - border-bottom: 1px solid @secondary__background-color; - min-height: 4.5rem; - padding-inline-start: 4.5rem !important; - text-decoration: none; - color: @main__font-color; - - .avatar { - display: flex; - align-items: center; - justify-content: center; - position: absolute; - inset-inline-start: .5625rem !important; - width: 3.375rem !important; - height: 3.375rem !important; - - &:before { - font-family: @icon-font; - content: @icon__image; - color: @main__font-color; - position: absolute; - font-size: 24px; - } - - &:after { - content: ''; - border: 35px solid @secondary__background-color; - border-radius: 100%; - } - } - - .dialog { - display: flex; - justify-content: space-between; - align-items: center; - - &.title { - height: 1.375rem; - font-weight: bold; - } - - &.subtitle { - height: 1.375rem; - margin-top: .125rem; - color: @secondary__font-color; - - &.unread { - color: @main__font-color; - transform-style: preserve-3d; - - &:after { - content: ''; - right: -5px; - position: absolute; - border: 10px solid @secondary__font-color; - border-radius: 100%; - transform: translateZ(-1px); - } - } - } - } - } + .messenger { + flex: 2; + display: flex; + align-items: center; + background: @secondary__background-color; } - } - .messenger { - flex: 2; - display: flex; - justify-content: center; - align-items: center; - background: @secondary__background-color; } } diff --git a/src/pages/chat/chat.ts b/src/pages/chat/chat.ts index 855281c..bd435da 100644 --- a/src/pages/chat/chat.ts +++ b/src/pages/chat/chat.ts @@ -1,13 +1,33 @@ import './chat.less'; import Block, {Props} from "../../core/Block"; import ChatPageTmpl from './chat.hbs?raw'; +import { goToSettings } from '../../utils/router'; +import Store from '../../core/Store'; +import { connect } from "../../utils/connect"; -export class ChatPage extends Block { +class ChatPageBase extends Block { constructor(props: Props) { - super(props); + super({ + ...props, + goToSettings: () => { + goToSettings(); + }, + onChatCreate: (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + Store.set({isCreateChatModalOpen: true}); + } + }); } render(): string { return ChatPageTmpl; } } + +export const ChatPage = connect((state) => { + return { + currentChat: state.chats?.find((chat: any) => chat.id === state.currentChat) || null, + chats: state.chats, + } +})(ChatPageBase); diff --git a/src/pages/login-form/login-form.hbs b/src/pages/login-form/login-form.hbs index 2f40243..f826a17 100644 --- a/src/pages/login-form/login-form.hbs +++ b/src/pages/login-form/login-form.hbs @@ -2,16 +2,16 @@

Sign In

diff --git a/src/pages/login-form/login-form.ts b/src/pages/login-form/login-form.ts index e7f104b..d9c63db 100644 --- a/src/pages/login-form/login-form.ts +++ b/src/pages/login-form/login-form.ts @@ -1,15 +1,60 @@ import './login-form.less'; import LoginFormTmpl from './login-form.hbs?raw'; import { BaseForm } from '../../core/BaseForm'; +import { goToRegister } from '../../utils/router'; +import Block from "../../core/Block"; +import {Input} from "../../components/input/input"; +import AuthController from "../../controllers/AuthController"; export class LoginPage extends BaseForm { - render(): string { - return LoginFormTmpl; + constructor(props: Record = {}) { + super({ + ...props, + onLogin: (event: Event | undefined) => { + if (!event) { + return; + } + event.preventDefault(); + + const formData: Record = {}; + let isValid: boolean = true; + + Object.values(this.children).forEach((child: Block | Element) => { + if (child instanceof Input) { + const name = child.props.name as string; + formData[name] = child.getValue(); + if (!child.validate()) { + isValid = false; + } + } + }); + + if (isValid) { + this.submitForm(formData); + } else { + console.error('Form validation failed'); + } + }, + goToRegistration: () => { + goToRegister(); + } + }); } - protected onValid(formData: Record) { - console.log('Login successful', formData); + private async submitForm(formData: Record) { + try { + const userData = { + login: formData.login, + password: formData.password + }; - this.navigate('ChatPage'); + await AuthController.login(userData); + } catch (error) { + console.error('Registration error:', error); + } + } + + render(): string { + return LoginFormTmpl; } } diff --git a/src/pages/main-page/main-page.hbs b/src/pages/main-page/main-page.hbs deleted file mode 100644 index 060474a..0000000 --- a/src/pages/main-page/main-page.hbs +++ /dev/null @@ -1,12 +0,0 @@ -
- -
diff --git a/src/pages/main-page/main-page.ts b/src/pages/main-page/main-page.ts deleted file mode 100644 index 1b0a2f9..0000000 --- a/src/pages/main-page/main-page.ts +++ /dev/null @@ -1,11 +0,0 @@ -import Block, {Children, Props} from "../../core/Block"; -import MainPageTmpl from './main-page.hbs?raw'; - -export class MainPage extends Block { - constructor(props: Props | Children) { - super({props})} - - render(): string { - return MainPageTmpl; - } -} diff --git a/src/pages/profile-edit-password/profile-edit-password.hbs b/src/pages/profile-edit-password/profile-edit-password.hbs index 60e97a7..5347153 100644 --- a/src/pages/profile-edit-password/profile-edit-password.hbs +++ b/src/pages/profile-edit-password/profile-edit-password.hbs @@ -1,6 +1,6 @@
@@ -14,7 +14,7 @@
- {{{ Button class="profile button-save" type="submit" label="Save" onClick=onValid }}} + {{{ Button class="profile button-save" type="submit" label="Save" onClick=onPasswordSave }}}
diff --git a/src/pages/profile-edit-password/profile-edit-password.ts b/src/pages/profile-edit-password/profile-edit-password.ts index 7b498cc..300ad25 100644 --- a/src/pages/profile-edit-password/profile-edit-password.ts +++ b/src/pages/profile-edit-password/profile-edit-password.ts @@ -1,15 +1,74 @@ import '../profile/profile.less'; import ProfileEditPasswordPageTmpl from './profile-edit-password.hbs?raw'; import { BaseForm } from '../../core/BaseForm'; +import { Props } from '../../core/Block'; +import {Input} from "../../components/input/input"; +import { router, goToSettings } from '../../utils/router'; +import UserController from "../../controllers/UserController"; +import Store from '../../core/Store'; export class ProfileEditPasswordPage extends BaseForm { - render(): string { - return ProfileEditPasswordPageTmpl; + constructor(props: Props) { + super({ + ...props, + goBack() { + router.back(); + }, + onPasswordSave: (event: Event) => { + if (!event) return; + event.preventDefault(); + + const formData: Record = {}; + let isValid: boolean = true; + let hasValues: boolean = false; + + Object.entries(this.children).forEach(([_key, child]) => { + if (child instanceof Input) { + const name = child.props.name as string; + const value = child.getValue() as string; + formData[name] = value; + + if (value.trim() !== '') { + hasValues = true; + const validationResult = child.validate(); + + if (!validationResult) { + isValid = false; + } + } + } + }); + + if (hasValues && isValid) { + this.savePassword(formData); + } else if (!hasValues) { + return; + } else { + console.error('Form validation failed, not saving changes'); + } + } + }); } - protected onValid(formData: Record) { - console.log('Profile password edit successful', formData); + private async savePassword(formData: Record) { + try { + const passwordData = { + oldPassword: formData.oldPassword, + newPassword: formData.newPassword + }; + + await UserController.changePassword(passwordData); + Store.setNotificationMessage('Password has been successfully changed.'); + goToSettings(); + } catch (error) { + console.error('Ошибка при смене пароля:', error); + this.setProps({ + error: error instanceof Error ? error.message : 'Произошла неизвестная ошибка при смене пароля' + }); + } + } - this.navigate('ProfilePage'); + render(): string { + return ProfileEditPasswordPageTmpl; } } diff --git a/src/pages/profile-edit/profile-edit.hbs b/src/pages/profile-edit/profile-edit.hbs deleted file mode 100644 index a132e47..0000000 --- a/src/pages/profile-edit/profile-edit.hbs +++ /dev/null @@ -1,28 +0,0 @@ -
- -
-
-
-
- {{{ Input type='email' name='email' class="email" id="email" label='Email' value="ivan@gmail.com" }}} -
-
- {{{ Input type='input' name='login' class="login" id="login" label='Login' value="ivanivanov" }}} -
-
- {{{ Input type='input' name='first_name' class="first_name" id="first_name" label='First Name' value="Ivan" }}} -
-
- {{{ Input type='input' name='second_name' class="second_name" id="second_name" label='Second Name' value="Ivanov" }}} -
-
- {{{ Input type='tel' name='phone' class="phone" id="phone" label='Phone Number' value="+78005553535" }}} -
-
- {{{ Button class="profile button-save" type="submit" label="Save" onClick=onValid }}} -
-
-
-
diff --git a/src/pages/profile-edit/profile-edit.ts b/src/pages/profile-edit/profile-edit.ts deleted file mode 100644 index 5662ff0..0000000 --- a/src/pages/profile-edit/profile-edit.ts +++ /dev/null @@ -1,15 +0,0 @@ -import '../profile/profile.less'; -import ProfileEditPageTmpl from './profile-edit.hbs?raw'; -import { BaseForm } from '../../core/BaseForm'; - -export class ProfileEditPage extends BaseForm { - render(): string { - return ProfileEditPageTmpl; - } - - protected onValid(formData: Record) { - console.log('Profile edit successful', formData); - - this.navigate('ProfilePage'); - } -} diff --git a/src/pages/profile/profile.hbs b/src/pages/profile/profile.hbs index 3fbfda2..1f6039a 100644 --- a/src/pages/profile/profile.hbs +++ b/src/pages/profile/profile.hbs @@ -1,32 +1,49 @@
-
-
-
- Email +
+ {{{ Avatar avatar=user.avatar name=user.login onClick=onClickAvatar }}} + {{{ Input class="avatar-uploader" style="display: none" id="avatar-uploader" type="file" accept="image/*" skipValidation=true onChange=changeUserAvatar }}} +
+
+ {{user.first_name}} +
+
+
+ {{{ Input type='email' name='email' class="email" id="email" label='Email' value=user.email readonly=readonly }}}
-
- Login +
+ {{{ Input type='input' name='login' class="login" id="login" label='Login' value=user.login readonly=readonly }}}
-
- First NameIvan +
+ {{{ Input type='input' name='first_name' class="first_name" id="first_name" label='First Name' value=user.first_name readonly=readonly }}}
-
- Second NameIvanov +
+ {{{ Input type='input' name='second_name' class="second_name" id="second_name" label='Second Name' value=user.second_name readonly=readonly }}}
-
- Phone +
+ {{{ Input type='tel' name='phone' class="phone" id="phone" label='Phone Number' value=user.phone readonly=readonly }}}
-
+ {{#if editable }} +
+ {{{ Button class="profile button-save" type="submit" label="Save" onClick=saveUserChanges }}} +
+ {{/if}} + + {{#if notificationMessage}} +
{{ notificationMessage }}
+ {{/if}}
- {{{ Button class="profile button-edit-profile" label="Change Profile" page="ProfileEditPage" }}} + {{{ Button class="profile button-edit-profile" label="Change Profile" onClick=toggleEdit }}}
- {{{ Button class="profile button-edit-password" label="Change Password" page="ProfileEditPasswordPage" }}} + {{{ Button class="profile button-edit-password" label="Change Password" onClick=goToPasswordEdit }}} +
+
+ {{{ Button class="profile button-logout" label="Log Out" onClick=goToLogout }}}
diff --git a/src/pages/profile/profile.less b/src/pages/profile/profile.less index 7014cb5..c09dab1 100644 --- a/src/pages/profile/profile.less +++ b/src/pages/profile/profile.less @@ -38,20 +38,79 @@ padding: 3rem; .avatar { - margin: 5rem 0; - border: 70px solid @main__background-color; - border-radius: 100%; + .image { + margin: 0 0 5rem 0; + border: 70px solid @main__background-color; + border-radius: 100%; + overflow: hidden; + + &:hover { + cursor: pointer; + border-color: rgba(0, 0, 0, 0.5); + + &:before { + content: 'Change avatar'; + font-size: 15px; + } + } - &:before { - font-family: @icon-font; - content: @icon__image; - color: @main__font-color; - font-size: 40px; - position: absolute; - transform: translate(-50%, -50%); + &:before { + font-family: @icon-font; + content: @icon__image; + color: @main__font-color; + font-size: 40px; + position: absolute; + transform: translate(-50%, -50%); + z-index: 1; + } + + &.uploaded { + border: none; + width: 180px; + height: 180px; + position: relative; + + img { + border-radius: 100%; + width: 180px; + height: 180px; + } + + &:hover { + cursor: pointer; + background: rgba(0, 0, 0, 0.5); + + &:after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + } + + &:before { + content: 'Change avatar' !important; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 15px; + } + } + + &:before { + content: ''; + } + } } } + .avatar-uploader { + visibility: hidden; + } + .info { flex-direction: column; width: 60%; @@ -125,6 +184,15 @@ } } + .success-message { + color: green; + background-color: #e6ffe6; + border: 1px solid #b3ffb3; + padding: 10px; + margin-top: 10px; + border-radius: 4px; + } + .edit-buttons { flex-direction: column; width: 60%; @@ -142,6 +210,15 @@ margin: 0; border-bottom: 2px solid @profile__info-underline-color; } + + .button-logout { + .button(@margin: 0.5rem 0, @background: none, @padding: 0, @color: @secondary__button-background-color); + + &:hover { + .custom-button-hover(@color: @secondary__button-background-color); + opacity: 70%; + } + } } } } diff --git a/src/pages/profile/profile.ts b/src/pages/profile/profile.ts index f4882bb..3efa316 100644 --- a/src/pages/profile/profile.ts +++ b/src/pages/profile/profile.ts @@ -1,13 +1,189 @@ import './profile.less'; -import Block, {Props} from "../../core/Block"; +import Block, {Props} from '../../core/Block'; import ProfilePageTmpl from './profile.hbs?raw'; +import {goToPasswordEdit, goToMessenger} from '../../utils/router'; +import {connect} from '../../utils/connect'; +import {Indexed} from '../../utils/utils'; +import AuthController from '../../controllers/AuthController'; +import {Input} from "../../components/input/input"; +import UserController from "../../controllers/UserController"; +import Store from '../../core/Store'; + +export class ProfilePageBase extends Block { + private successMessageTimeout: number | null = null; -export class ProfilePage extends Block { constructor(props: Props) { - super(props); + super({ + ...props, + readonly: true, + editable: false, + goToPasswordEdit() { + goToPasswordEdit(); + }, + goBack() { + goToMessenger(); + }, + goToLogout() { + AuthController.logout(); + }, + toggleEdit: () => this.toggleEditMode(), + saveUserChanges: (event: Event) => { + if (!event) return; + event.preventDefault(); + + const formData: Record = {}; + let isValid: boolean = true; + let hasValues: boolean = false; + + Object.entries(this.children).forEach(([_key, child]) => { + if (child instanceof Input) { + const name = child.props.name as string; + const value = child.getValue() as string; + formData[name] = value; + + if (value.trim() !== '') { + hasValues = true; + const validationResult = child.validate(); + + if (!validationResult) { + isValid = false; + } + } + } + }); + + if (hasValues && isValid) { + this.saveChanges(formData); + } else if (!hasValues) { + return; + } else { + console.error('Form validation failed, not saving changes'); + } + }, + changeUserAvatar: (event: Event) => { + if (!event) return; + event.preventDefault(); + + const input = event?.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + const file = input.files[0]; + this.uploadAvatar(file); + } + }, + onClickAvatar: (event: Event) => { + if (!event) return; + event.preventDefault(); + this.onClickAvatar(); + } + }); + } + + componentDidMount() { + super.componentDidMount(); + this.checkAndStartTimer(); + } + + toggleEditMode() { + this.setProps({ + isEditable: !this.props.isEditable, + editable: !this.props.editable, + }); + this.updateInputsReadonlyState(); + } + + updateInputsReadonlyState() { + const inputs = this._element?.querySelectorAll('input') as NodeListOf; + inputs.forEach(input => { + input.readOnly = !this.props.isEditable; + }); + } + + private async saveChanges(formData: Record) { + try { + const userData = { + first_name: formData.first_name, + second_name: formData.second_name, + login: formData.login, + email: formData.email, + phone: formData.phone, + }; + + const updatedUser = await UserController.changeUserData(userData); + + if (updatedUser) { + this.setProps({ + user: updatedUser, + isEditable: false, + editable: false + }); + Store.setNotificationMessage('Profile has been successfully updated.'); + this.updateInputsReadonlyState(); + this.checkAndStartTimer(); + } + } catch (error) { + console.error('Error updating user data:', error); + } + } + + async uploadAvatar(file: File) { + try { + const data = new FormData(); + data.append('avatar', file, file.name); + const update = await UserController.changeAvatar(data); + + if (update) { + this.setProps({user: update}); + } + } catch (error) { + console.error('AvatarUploader: Error uploading avatar:', error); + } + } + + static getStateToProps(state: Indexed): Indexed { + return { + user: state.user || null, + }; + } + + private checkAndStartTimer() { + if (this.props.notificationMessage) { + this.startSuccessMessageTimer(); + } + } + + private startSuccessMessageTimer() { + if (this.successMessageTimeout) { + clearTimeout(this.successMessageTimeout); + } + + this.successMessageTimeout = window.setTimeout(() => { + Store.setNotificationMessage(''); + }, 2500); + } + + onClickAvatar() { + const avatarUploader: HTMLInputElement | undefined = this._element?.querySelector('#avatar-uploader') as HTMLInputElement | undefined; + if (avatarUploader) { + avatarUploader.click(); + } } render(): string { return ProfilePageTmpl; } + + protected componentDidUpdate(oldProps: any, newProps: any): boolean { + if (oldProps.notificationMessage !== newProps.notificationMessage) { + this.checkAndStartTimer(); + } + + return true; + } } + +export const ProfilePage = connect((state) => { + return { + user: state.user, + notificationMessage: state.notificationMessage + }; +})(ProfilePageBase); diff --git a/src/pages/registration-form/registration-form.hbs b/src/pages/registration-form/registration-form.hbs index 7c3e9e1..4384d53 100644 --- a/src/pages/registration-form/registration-form.hbs +++ b/src/pages/registration-form/registration-form.hbs @@ -20,7 +20,7 @@ {{{ Input type='tel' name='phone' class="phone" id="phone" label='Phone Number' }}}
- {{{ Button class="registration__button-registration" type="submit" label='Create account' onClick=onValid }}} + {{{ Button class="registration__button-registration" type="submit" label='Create account' onClick=onRegistration }}}
diff --git a/src/pages/registration-form/registration-form.ts b/src/pages/registration-form/registration-form.ts index b2d12fe..c4a683f 100644 --- a/src/pages/registration-form/registration-form.ts +++ b/src/pages/registration-form/registration-form.ts @@ -1,15 +1,62 @@ import './registration-form.less'; import RegistrationFormTmpl from './registration-form.hbs?raw'; import { BaseForm } from '../../core/BaseForm'; +import Block, { Props } from '../../core/Block'; +import { goToMessenger } from '../../utils/router'; +import { Input } from "../../components/input/input"; +import AuthController from "../../controllers/AuthController"; export class RegistrationPage extends BaseForm { - render(): string { - return RegistrationFormTmpl; + constructor(props: Props) { + super({ + props, + onRegistration: (event: Event | undefined) => { + if (!event) return; + event.preventDefault(); + + const formData: Record = {}; + let isValid: boolean = true; + + Object.values(this.children).forEach((child: Block | Element) => { + if (child instanceof Input) { + const name = child.props.name as string; + formData[name] = child.getValue(); + if (!child.validate()) { + isValid = false; + } + } + }); + + if (isValid) { + this.submitForm(formData); + } else { + console.error('Form validation failed'); + } + } + }); } - protected onValid(formData: Record) { - console.log('Account registration successful', formData); + private async submitForm(formData: Record) { + try { + const userData = { + first_name: formData.first_name, + second_name: formData.second_name, + login: formData.login, + email: formData.email, + password: formData.password, + phone: formData.phone, + }; - this.navigate('ChatPage'); + const success = await AuthController.createUser(userData); + if (success) { + goToMessenger(); + } + } catch (error) { + console.error('Registration error:', error); + } + } + + render(): string { + return RegistrationFormTmpl; } } diff --git a/src/style.less b/src/style.less index 3663bec..9af64d1 100644 --- a/src/style.less +++ b/src/style.less @@ -34,3 +34,14 @@ body { } } } + +.modals-overlay { + z-index: 901; + backdrop-filter: none; + background: rgba(0, 0, 0, 0.35); + bottom: 0; + left: 0; + position: fixed; + right: 0; + top: 0; +} diff --git a/src/utils/connect.ts b/src/utils/connect.ts new file mode 100644 index 0000000..934a2c5 --- /dev/null +++ b/src/utils/connect.ts @@ -0,0 +1,33 @@ +import Store, { StoreEvents } from '../core/Store'; +import Block from '../core/Block'; +import isEqual from '../utils/isEqual'; + +export function connect(mapStateToProps: (state: any) => any) { + return function(Component: typeof Block) { + return class extends Component { + private onChangeStoreCallback: () => void; + + constructor(props: any) { + let state = mapStateToProps(Store.getState()); + super({ ...props, ...mapStateToProps(state) }); + + this.onChangeStoreCallback = () => { + const newState = mapStateToProps(Store.getState()); + + if (!isEqual(state, newState)) { + this.setProps({ ...newState }); + } + + state = newState; + } + + Store.on(StoreEvents.Updated, this.onChangeStoreCallback); + } + + componentWillUnmount() { + super.componentWillUnmount(); + Store.off(StoreEvents.Updated, this.onChangeStoreCallback); + } + } + } +} diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts new file mode 100644 index 0000000..e632b81 --- /dev/null +++ b/src/utils/helpers.ts @@ -0,0 +1,27 @@ +import Handlebars from 'handlebars'; + +Handlebars.registerHelper('eq', function(a, b) { + return a == b; +}); + +Handlebars.registerHelper('formatDate', function(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60); + + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + const time = `${hours}:${minutes}`; + + if (diffInHours > 24) { + const options: Intl.DateTimeFormatOptions = { weekday: 'short' }; + const dayOfWeek = date.toLocaleDateString('en-EN', options); + return `${dayOfWeek}, ${time}`; + } else { + return time; + } +}); + +Handlebars.registerHelper('firstLetter', function(str: string) { + return str ? str.charAt(0).toUpperCase() : ''; +}); diff --git a/src/utils/hosts.ts b/src/utils/hosts.ts new file mode 100644 index 0000000..b000472 --- /dev/null +++ b/src/utils/hosts.ts @@ -0,0 +1,9 @@ +const HOST = 'https://ya-praktikum.tech/api/v2'; +const HOST_RESOURCES = 'https://ya-praktikum.tech/api/v2/resources'; +const socket = new WebSocket('wss://ya-praktikum.tech/ws/chats/'); + +export { + HOST, + HOST_RESOURCES, + socket +} diff --git a/src/utils/isEqual.ts b/src/utils/isEqual.ts new file mode 100644 index 0000000..b83f7ac --- /dev/null +++ b/src/utils/isEqual.ts @@ -0,0 +1,51 @@ +function isEqual(a: any, b: any): boolean { + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return a === b; + } + + const aIsArray = Array.isArray(a); + const bIsArray = Array.isArray(b); + + if (aIsArray !== bIsArray) { + return false; + } + + if (aIsArray && bIsArray) { + if ((a as any[]).length !== (b as any[]).length) { + return false; + } + } else { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + if (aKeys.length !== bKeys.length) { + return false; + } + } + + for (const key in a) { + if (Object.prototype.hasOwnProperty.call(a, key)) { + if (!Object.prototype.hasOwnProperty.call(b, key)) { + return false; + } + + const aValue = (a as Record)[key]; + const bValue = (b as Record)[key]; + + if (typeof aValue === 'object' && aValue !== null) { + if (typeof bValue !== 'object' || bValue === null) { + return false; + } + if (!isEqual(aValue as object, bValue as object)) { + return false; + } + } else if (aValue !== bValue) { + return false; + } + } + } + + return true; +} + +export default isEqual; diff --git a/src/utils/queryStringify.ts b/src/utils/queryStringify.ts new file mode 100644 index 0000000..7f512b9 --- /dev/null +++ b/src/utils/queryStringify.ts @@ -0,0 +1,41 @@ +type StringIndexed = Record; + +function queryStringify(data: StringIndexed): string | never { + if (typeof data !== 'object') { + throw new Error('Data must be object'); + } + + const keys = Object.keys(data); + return keys.reduce((res, key, index) => { + const value = data[key]; + const endLine = index < keys.length - 1 ? '&' : ''; + + if (Array.isArray(value)) { + const arrayValue = value.reduce( + (result, arrData, i) => ({ + ...result, + [`${key}[${i}]`]: arrData, + }), + {}, + ); + + return `${res}${queryStringify(arrayValue)}${endLine}`; + } + + if (typeof value === 'object') { + const objValue = Object.keys(value || {}).reduce( + (result, objKey) => ({ + ...result, + [`${key}[${objKey}]`]: value[objKey], + }), + {}, + ); + + return `${res}${queryStringify(objValue)}${endLine}`; + } + + return `${res}${key}=${value}${endLine}`; + }, ''); +} + +export default queryStringify; diff --git a/src/utils/router.ts b/src/utils/router.ts new file mode 100644 index 0000000..fd3d704 --- /dev/null +++ b/src/utils/router.ts @@ -0,0 +1,47 @@ +import Router from "../core/Router"; + +const router = new Router('#app'); + +const start = () => { + router.start(); +} + +const goToLogin = () => { + router.go('/login'); +} + +const goToRegister = () => { + router.go('/signup'); +} + +const goToMessenger = () => { + router.go('/messenger'); +} + +const goToSettings = () => { + router.go('/settings'); +} + +const goToPasswordEdit = () => { + router.go('/settings/edit-password'); +} + +const goToError404 = () => { + router.go('/404'); +} + +const goToError500 = () => { + router.go('/500'); +} + +export { + router, + start, + goToLogin, + goToRegister, + goToMessenger, + goToSettings, + goToPasswordEdit, + goToError404, + goToError500 +}; diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000..72a729a --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,31 @@ +export interface User { + id: number; + login: string; + first_name: string; + second_name: string; + display_name: string | null; + avatar: string | null; + email: string; + phone: string; +} + +export interface Chat { + id: number; + title: string; + avatar: string | null; + unreadCount: number; + lastMessage?: { + content: string; + time: string; + }; +} + +export interface MessageProps { + id: string; + chat_id: number; + time: string; + type: 'message' | 'file' | 'sticker'; + user_id: string; + content: string; + file?: File; +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000..254eeb6 --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,36 @@ +export type Indexed = { + [key in string]: T; +}; + +export function set(object: Indexed | unknown, path: string, value: unknown): Indexed | unknown { + if (typeof object !== 'object' || object === null) { + return object; + } + + if (typeof path !== 'string') { + throw new Error('path must be string'); + } + + const result = path.split('.').reduceRight((acc, key) => ({ + [key]: acc, + }), value as any); + return merge(object as Indexed, result); +} + +export function merge(lhs: Indexed, rhs: Indexed): Indexed { + for (const p in rhs) { + if (!rhs.hasOwnProperty(p)) { + continue; + } + try { + if (rhs[p].constructor === Object) { + rhs[p] = merge(lhs[p] as Indexed, rhs[p] as Indexed); + } else { + lhs[p] = rhs[p]; + } + } catch (e) { + lhs[p] = rhs[p]; + } + } + return lhs; +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 19ea56b..5a4c1c4 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -56,6 +56,8 @@ export const validateField = (fieldName: string, value: string): string | null = return 'The phone number must contain 10 to 15 digits and may begin with a plus sign'; } return null; + case 'chatTitle': + return null; default: console.warn(`Unknown field for validation: ${fieldName}`); return null; diff --git a/src/variables.less b/src/variables.less index a57cae0..f3de02d 100644 --- a/src/variables.less +++ b/src/variables.less @@ -1,7 +1,10 @@ @main__background-color: #252838; +@main__background-color_darker: #242637; +@main__background-hover-color: #3369F3; @main__font-color: #E2E2E4; @secondary__font-color: #9898B0; @main__button-background-color: #3369F3; +@secondary__button-background-color: #F44D4D; @main__button-background-hover: #3369f39e; @main__input-background-color: #252838; @@ -13,3 +16,5 @@ @main__error-color: #F44D4D; @main__icon-color: #CDCDCD; + +@messenger__out_message_background_color: #4C546F;