From f89e159272133ead1f043f298c431b60360da29c Mon Sep 17 00:00:00 2001 From: grabic21 Date: Thu, 21 Aug 2025 17:58:23 +0400 Subject: [PATCH 1/7] Add profile --- frontend/index.html | 43 +++++++++++--- frontend/src/css/_prifole.scss | 104 +++++++++++++++++++++++++++++++++ frontend/src/css/style.scss | 2 + frontend/src/images/pesel.svg | 15 +++++ frontend/src/main.ts | 3 +- frontend/src/profile.ts | 63 ++++++++++++++++++++ 6 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 frontend/src/css/_prifole.scss create mode 100644 frontend/src/images/pesel.svg create mode 100644 frontend/src/profile.ts diff --git a/frontend/index.html b/frontend/index.html index d63394a..4d81c80 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,7 +21,7 @@
- -
Loading...
- + + +
@@ -189,15 +191,40 @@
+ -

- Скоро будет... -

- +
+
+ Ваше фото + + +
+
+
+
+

Ваш ник

+ +
+
+ +
+
+
+
+

Ваше описание

+ +
+
+ +
+
+
+
+

Закрыть

- \ No newline at end of file + \ No newline at end of file diff --git a/frontend/src/css/_prifole.scss b/frontend/src/css/_prifole.scss new file mode 100644 index 0000000..d2cec1a --- /dev/null +++ b/frontend/src/css/_prifole.scss @@ -0,0 +1,104 @@ +@use "common/colors" as *; +@use "common/material" as *; + +.name-and-img{ + display: flex; + flex-direction: row; + gap: 20px; + + #icon-profile{ + display: flex; + flex-direction: column; + justify-content: center; + #preview { + width: 200px; + height: auto; + display: flex; + img{ + width: 96px; + height: 95px; + } + } + #uploadButton { + background-color: rgba(0,0,0,0.5); + color: #fff; + border: none; + padding: 8px 16px; + cursor: pointer; + border-radius: 4px; + font-size: 16px; + opacity: 0.8; + } + #uploadButton:hover { + opacity: 1; + } + /* скрываем чекбокс, чтобы он был только триггером */ + #fileInput { + display: none; + } + } + .name-and-discription{ + display: flex; + flex-direction: column; + gap: 0; + .name{ + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + #name-title{ + display: flex; + margin: 0; + #text-nik{ + display: flex; + } + p{ + display: flex; + color: $color-dark-surface; + align-items: center; + font-size: 24px; + } + } + #change-name{ + display: flex; + align-items: center; + cursor: pointer; + img{ + align-items: center; + width: 30px; + height: 30px; + + } + } + } + .discription{ + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + #discription-title{ + display: flex; + margin: 0; + p{ + display: flex; + color: $color-dark-surface; + align-items: center; + font-size: 18px; + } + } + #change-discription{ + display: flex; + align-items: center; + cursor: pointer; + img{ + align-items: center; + width: 30px; + height: 30px; + + } + } + } + } +} diff --git a/frontend/src/css/style.scss b/frontend/src/css/style.scss index b5aeb78..1a3ba55 100644 --- a/frontend/src/css/style.scss +++ b/frontend/src/css/style.scss @@ -1,5 +1,6 @@ @use "auth"; @use "chat"; +@use "prifole"; @use "panelchat"; @use "common/animations"; @use "common/components"; @@ -25,6 +26,7 @@ body { } mdui-dialog { + > *:first-child { margin-block-start: 0; } diff --git a/frontend/src/images/pesel.svg b/frontend/src/images/pesel.svg new file mode 100644 index 0000000..7290bcc --- /dev/null +++ b/frontend/src/images/pesel.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts index ce8bbf8..14357ba 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -5,4 +5,5 @@ import "./links"; import "./material"; import "./chat"; import "./leftpanel"; -import "./init"; \ No newline at end of file +import "./init"; +import "./profile"; \ No newline at end of file diff --git a/frontend/src/profile.ts b/frontend/src/profile.ts new file mode 100644 index 0000000..946d004 --- /dev/null +++ b/frontend/src/profile.ts @@ -0,0 +1,63 @@ +const fileInput = document.getElementById('fileInput') as HTMLInputElement; +const preview = document.getElementById('preview') as HTMLImageElement; +const preview1 = document.getElementById('preview1') as HTMLImageElement; +const button = document.getElementById('uploadButton')!; + +if (fileInput && preview && button) { + // при клике по кнопке откроем диалог выбора файла + button.addEventListener('click', () => { + fileInput.click(); + }); + + // при выборе файла — показываем его + fileInput.addEventListener('change', () => { + const file: File | null = fileInput.files ? fileInput.files[0] : null; + if (file) { + const reader: FileReader = new FileReader(); + reader.onload = (e: ProgressEvent) => { + if (e.target && typeof e.target.result === 'string') { + preview.src = e.target.result; + preview1.src = e.target.result; + preview.style.display = 'block'; + } + }; + reader.readAsDataURL(file); + } + }); +} + +const textDiv = document.getElementById('text-nik')!; +const input = document.getElementById('nik') as HTMLInputElement; +const button1 = document.getElementById('change-name')!; + +button1.addEventListener('click', () => { + if (input.style.display === 'none') { + // Переводим в режим редактирования + input.value = textDiv.textContent || ''; + textDiv.style.display = 'none'; + input.style.display = 'flex'; + } else { + // Сохраняем изменения + textDiv.textContent = input.value; + textDiv.style.display = 'flex'; + input.style.display = 'none'; + } +}); + +const textDiv2 = document.getElementById('text-discr')!; +const input2 = document.getElementById('discr') as HTMLInputElement; +const button2 = document.getElementById('change-discription')!; + +button2.addEventListener('click', () => { + if (input2.style.display === 'none') { + // Переводим в режим редактирования + input2.value = textDiv2.textContent || ''; + textDiv2.style.display = 'none'; + input2.style.display = 'flex'; + } else { + // Сохраняем изменения + textDiv2.textContent = input2.value; + textDiv2.style.display = 'flex'; + input2.style.display = 'none'; + } +}); \ No newline at end of file From a47d48318f71f8bfead2f86274bc7a0f790763fa Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 22 Aug 2025 13:19:14 +0300 Subject: [PATCH 2/7] Start the profile improvement --- frontend/index.html | 29 ++------- frontend/src/css/_prifole.scss | 104 --------------------------------- frontend/src/css/_profile.scss | 34 +++++++++++ frontend/src/css/style.scss | 2 +- 4 files changed, 40 insertions(+), 129 deletions(-) delete mode 100644 frontend/src/css/_prifole.scss create mode 100644 frontend/src/css/_profile.scss diff --git a/frontend/index.html b/frontend/index.html index 4d81c80..625ca33 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -193,31 +193,12 @@ -
-
- Ваше фото - - +
+
+ Ваше фото
-
-
-
-

Ваш ник

- -
-
- -
-
-
-
-

Ваше описание

- -
-
- -
-
+
+
Loading...
diff --git a/frontend/src/css/_prifole.scss b/frontend/src/css/_prifole.scss deleted file mode 100644 index d2cec1a..0000000 --- a/frontend/src/css/_prifole.scss +++ /dev/null @@ -1,104 +0,0 @@ -@use "common/colors" as *; -@use "common/material" as *; - -.name-and-img{ - display: flex; - flex-direction: row; - gap: 20px; - - #icon-profile{ - display: flex; - flex-direction: column; - justify-content: center; - #preview { - width: 200px; - height: auto; - display: flex; - img{ - width: 96px; - height: 95px; - } - } - #uploadButton { - background-color: rgba(0,0,0,0.5); - color: #fff; - border: none; - padding: 8px 16px; - cursor: pointer; - border-radius: 4px; - font-size: 16px; - opacity: 0.8; - } - #uploadButton:hover { - opacity: 1; - } - /* скрываем чекбокс, чтобы он был только триггером */ - #fileInput { - display: none; - } - } - .name-and-discription{ - display: flex; - flex-direction: column; - gap: 0; - .name{ - display: flex; - flex-direction: row; - flex-wrap: wrap; - gap: 10px; - justify-content: center; - #name-title{ - display: flex; - margin: 0; - #text-nik{ - display: flex; - } - p{ - display: flex; - color: $color-dark-surface; - align-items: center; - font-size: 24px; - } - } - #change-name{ - display: flex; - align-items: center; - cursor: pointer; - img{ - align-items: center; - width: 30px; - height: 30px; - - } - } - } - .discription{ - display: flex; - flex-direction: row; - flex-wrap: wrap; - gap: 10px; - justify-content: center; - #discription-title{ - display: flex; - margin: 0; - p{ - display: flex; - color: $color-dark-surface; - align-items: center; - font-size: 18px; - } - } - #change-discription{ - display: flex; - align-items: center; - cursor: pointer; - img{ - align-items: center; - width: 30px; - height: 30px; - - } - } - } - } -} diff --git a/frontend/src/css/_profile.scss b/frontend/src/css/_profile.scss new file mode 100644 index 0000000..deae3bb --- /dev/null +++ b/frontend/src/css/_profile.scss @@ -0,0 +1,34 @@ +@use "common/colors" as *; +@use "common/material" as *; + +#profile-dialog { + .header-top { + display: flex; + flex-direction: row; + gap: 16px; + position: relative; + + .left { + $size: 70px; + + width: $size; + height: $size; + + #profile-picture { + width: $size; + height: $size; + border-radius: 50%; + } + } + + .right { + display: flex; + flex-direction: column; + justify-content: center; + + #profile-username { + font-size: larger; + } + } + } +} \ No newline at end of file diff --git a/frontend/src/css/style.scss b/frontend/src/css/style.scss index 1a3ba55..682b0ef 100644 --- a/frontend/src/css/style.scss +++ b/frontend/src/css/style.scss @@ -1,6 +1,6 @@ @use "auth"; @use "chat"; -@use "prifole"; +@use "profile"; @use "panelchat"; @use "common/animations"; @use "common/components"; From 803f6851800b99ba6e36ebaccb7bd9bf0111ccfc Mon Sep 17 00:00:00 2001 From: grabic21 Date: Fri, 22 Aug 2025 14:26:53 +0400 Subject: [PATCH 3/7] Make a settings template --- frontend/index.html | 93 ++++++++++++++++++++++++- frontend/src/css/_panelchat.scss | 1 - frontend/src/css/_settings.scss | 99 +++++++++++++++++++++++++++ frontend/src/css/style.scss | 1 + frontend/src/main.ts | 1 + frontend/src/material.ts | 3 + frontend/src/profile.ts | 114 +++++++++++++++---------------- frontend/src/settings.ts | 74 ++++++++++++++++++++ 8 files changed, 327 insertions(+), 59 deletions(-) create mode 100644 frontend/src/css/_settings.scss create mode 100644 frontend/src/settings.ts diff --git a/frontend/index.html b/frontend/index.html index 625ca33..dc6280e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -152,7 +152,7 @@
- +
@@ -206,6 +206,97 @@ Закрыть

+ +
+
+
+ + Настройки +
+
+ + Профиль + Уведомления + Внешний вид + Безопасность + Язык + Хранилище + Помощь + О приложении + +
+
+

Профиль

+ + + + Сохранить изменения +
+ +
+

Уведомления

+ Новые сообщения + Звуковые уведомления + Уведомления о статусе + Email уведомления +
+ +
+

Внешний вид

+ + Тёмная + Светлая + Авто + + + Маленький + Средний + Большой + +
+ +
+

Безопасность

+ Изменить пароль + Двухфакторная аутентификация + Автоматический выход +
+ +
+

Язык

+ + Русский + English + Español + +
+ +
+

Хранилище

+

Использовано: 2.5 ГБ из 10 ГБ

+ + Очистить кэш +
+ +
+

Помощь

+ Руководство пользователя + Связаться с поддержкой + FAQ +
+ +
+

О приложении

+

Версия: 1.0.0

+

© 2024 Boost Chat. Все права защищены.

+ Политика конфиденциальности + Условия использования +
+
+
+
+
+
\ No newline at end of file diff --git a/frontend/src/css/_panelchat.scss b/frontend/src/css/_panelchat.scss index d9811d2..a151459 100644 --- a/frontend/src/css/_panelchat.scss +++ b/frontend/src/css/_panelchat.scss @@ -89,7 +89,6 @@ img { $size: 45px; - display: flex; border-radius: 50%; width: $size; diff --git a/frontend/src/css/_settings.scss b/frontend/src/css/_settings.scss new file mode 100644 index 0000000..4607769 --- /dev/null +++ b/frontend/src/css/_settings.scss @@ -0,0 +1,99 @@ +@use "common/colors" as *; +@use "common/material" as *; + +#settings-dialog { + .fullscreen-wrapper { + display: flex; + align-items: center; + justify-content: center; + height: calc(100vh - (1.5rem * 2)); + width: 100%; + position: relative; + + #settings-dialog-inner { + max-width: 1200px; + max-height: 1000px; + width: 100%; + height: 100%; + + .header { + display: flex; + flex-direction: row; + gap: 10px; + margin-bottom: 16px; + } + + #settings-menu { + display: flex; + flex-direction: row; + gap: 16px; + + mdui-list { + max-width: 280px; + padding-right: 16px; + overflow-y: auto; + } + + .screen { + flex: 1; + overflow-y: auto; + position: relative; + + .settings-panel { + display: flex; + flex-direction: column; + gap: 16px; + opacity: 0; + visibility: hidden; + transform: translateY(20px); + transition: opacity 0.3s ease, transform 0.3s ease, visibility 0.3s ease; + position: absolute; + top: 0; + left: 0; + width: 100%; + + &.active { + opacity: 1; + visibility: visible; + transform: translateY(0); + position: relative; + } + + h3 { + margin: 0 0 16px 0; + color: $color-dark-on-surface; + } + + mdui-text-field, + mdui-select, + mdui-switch, + mdui-button { + margin-bottom: 8px; + } + + mdui-switch { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid $color-dark-outline; + + &:last-child { + border-bottom: none; + } + } + + p { + margin: 8px 0; + color: $color-dark-on-surface-variant; + } + + mdui-linear-progress { + margin: 16px 0; + } + } + } + } + } + } +} \ No newline at end of file diff --git a/frontend/src/css/style.scss b/frontend/src/css/style.scss index 682b0ef..3906aeb 100644 --- a/frontend/src/css/style.scss +++ b/frontend/src/css/style.scss @@ -1,6 +1,7 @@ @use "auth"; @use "chat"; @use "profile"; +@use "settings"; @use "panelchat"; @use "common/animations"; @use "common/components"; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 14357ba..0a92896 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -4,6 +4,7 @@ import "mdui/mdui.css"; import "./links"; import "./material"; import "./chat"; +import "./settings"; import "./leftpanel"; import "./init"; import "./profile"; \ No newline at end of file diff --git a/frontend/src/material.ts b/frontend/src/material.ts index 59d9a91..a8dd8e1 100644 --- a/frontend/src/material.ts +++ b/frontend/src/material.ts @@ -9,6 +9,9 @@ import 'mdui/components/fab'; import 'mdui/components/dialog'; import 'mdui/components/button'; import 'mdui/components/text-field'; +import 'mdui/components/button-icon'; +import 'mdui/components/top-app-bar'; +import 'mdui/components/top-app-bar-title'; import { setColorScheme } from 'mdui/functions/setColorScheme.js'; diff --git a/frontend/src/profile.ts b/frontend/src/profile.ts index 946d004..0377d9e 100644 --- a/frontend/src/profile.ts +++ b/frontend/src/profile.ts @@ -1,63 +1,63 @@ -const fileInput = document.getElementById('fileInput') as HTMLInputElement; -const preview = document.getElementById('preview') as HTMLImageElement; -const preview1 = document.getElementById('preview1') as HTMLImageElement; -const button = document.getElementById('uploadButton')!; +// const fileInput = document.getElementById('fileInput') as HTMLInputElement; +// const preview = document.getElementById('preview') as HTMLImageElement; +// const preview1 = document.getElementById('preview1') as HTMLImageElement; +// const button = document.getElementById('uploadButton')!; -if (fileInput && preview && button) { - // при клике по кнопке откроем диалог выбора файла - button.addEventListener('click', () => { - fileInput.click(); - }); +// if (fileInput && preview && button) { +// // при клике по кнопке откроем диалог выбора файла +// button.addEventListener('click', () => { +// fileInput.click(); +// }); - // при выборе файла — показываем его - fileInput.addEventListener('change', () => { - const file: File | null = fileInput.files ? fileInput.files[0] : null; - if (file) { - const reader: FileReader = new FileReader(); - reader.onload = (e: ProgressEvent) => { - if (e.target && typeof e.target.result === 'string') { - preview.src = e.target.result; - preview1.src = e.target.result; - preview.style.display = 'block'; - } - }; - reader.readAsDataURL(file); - } - }); -} +// // при выборе файла — показываем его +// fileInput.addEventListener('change', () => { +// const file: File | null = fileInput.files ? fileInput.files[0] : null; +// if (file) { +// const reader: FileReader = new FileReader(); +// reader.onload = (e: ProgressEvent) => { +// if (e.target && typeof e.target.result === 'string') { +// preview.src = e.target.result; +// preview1.src = e.target.result; +// preview.style.display = 'block'; +// } +// }; +// reader.readAsDataURL(file); +// } +// }); +// } -const textDiv = document.getElementById('text-nik')!; -const input = document.getElementById('nik') as HTMLInputElement; -const button1 = document.getElementById('change-name')!; +// const textDiv = document.getElementById('text-nik')!; +// const input = document.getElementById('nik') as HTMLInputElement; +// const button1 = document.getElementById('change-name')!; -button1.addEventListener('click', () => { - if (input.style.display === 'none') { - // Переводим в режим редактирования - input.value = textDiv.textContent || ''; - textDiv.style.display = 'none'; - input.style.display = 'flex'; - } else { - // Сохраняем изменения - textDiv.textContent = input.value; - textDiv.style.display = 'flex'; - input.style.display = 'none'; - } -}); +// button1.addEventListener('click', () => { +// if (input.style.display === 'none') { +// // Переводим в режим редактирования +// input.value = textDiv.textContent || ''; +// textDiv.style.display = 'none'; +// input.style.display = 'flex'; +// } else { +// // Сохраняем изменения +// textDiv.textContent = input.value; +// textDiv.style.display = 'flex'; +// input.style.display = 'none'; +// } +// }); -const textDiv2 = document.getElementById('text-discr')!; -const input2 = document.getElementById('discr') as HTMLInputElement; -const button2 = document.getElementById('change-discription')!; +// const textDiv2 = document.getElementById('text-discr')!; +// const input2 = document.getElementById('discr') as HTMLInputElement; +// const button2 = document.getElementById('change-discription')!; -button2.addEventListener('click', () => { - if (input2.style.display === 'none') { - // Переводим в режим редактирования - input2.value = textDiv2.textContent || ''; - textDiv2.style.display = 'none'; - input2.style.display = 'flex'; - } else { - // Сохраняем изменения - textDiv2.textContent = input2.value; - textDiv2.style.display = 'flex'; - input2.style.display = 'none'; - } -}); \ No newline at end of file +// button2.addEventListener('click', () => { +// if (input2.style.display === 'none') { +// // Переводим в режим редактирования +// input2.value = textDiv2.textContent || ''; +// textDiv2.style.display = 'none'; +// input2.style.display = 'flex'; +// } else { +// // Сохраняем изменения +// textDiv2.textContent = input2.value; +// textDiv2.style.display = 'flex'; +// input2.style.display = 'none'; +// } +// }); \ No newline at end of file diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts new file mode 100644 index 0000000..c575858 --- /dev/null +++ b/frontend/src/settings.ts @@ -0,0 +1,74 @@ +import type { Dialog } from "mdui/components/dialog"; + +const dialog = document.getElementById('settings-dialog') as Dialog; +const openButton = document.getElementById('settings-open')!; +const closeButton = document.getElementById('settings-close')!; + +// Settings panel management +const settingsList = document.querySelector('#settings-menu mdui-list')!; +const settingsPanels = document.querySelectorAll('.settings-panel'); + +// Initialize settings +function initializeSettings() { + // Add click listeners to all list items + const listItems = settingsList.querySelectorAll('mdui-list-item'); + + // Create a mapping between list items and their corresponding panels + const panelMapping = { + 'Профиль': 'profile-settings', + 'Уведомления': 'notifications-settings', + 'Внешний вид': 'appearance-settings', + 'Безопасность': 'security-settings', + 'Язык': 'language-settings', + 'Хранилище': 'storage-settings', + 'Помощь': 'help-settings', + 'О приложении': 'about-settings' + }; + + listItems.forEach((item) => { + item.addEventListener('click', () => { + // Remove active class from all items and panels + listItems.forEach(li => li.removeAttribute('active')); + settingsPanels.forEach(panel => panel.classList.remove('active')); + + // Add active class to clicked item + item.setAttribute('active', ''); + + // Show corresponding panel using the mapping + const itemText = item.textContent?.trim(); + const panelId = panelMapping[itemText as keyof typeof panelMapping]; + + if (panelId) { + const targetPanel = document.getElementById(panelId); + if (targetPanel) { + targetPanel.classList.add('active'); + } + } + }); + }); +} + +// Dialog event listeners +openButton.addEventListener('click', () => { + dialog.open = true; + // Reset to first panel when opening + const firstItem = settingsList.querySelector('mdui-list-item'); + const firstPanel = document.querySelector('.settings-panel'); + if (firstItem && firstPanel) { + settingsList.querySelectorAll('mdui-list-item').forEach(li => li.removeAttribute('active')); + settingsPanels.forEach(panel => panel.classList.remove('active')); + firstItem.setAttribute('active', ''); + firstPanel.classList.add('active'); + } +}); + +closeButton.addEventListener('click', () => { + dialog.open = false; +}); + +// Initialize when DOM is loaded +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeSettings); +} else { + initializeSettings(); +} From deb02a5e95baad251f3f5b80d4740e84379cb22b Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 22 Aug 2025 15:51:32 +0300 Subject: [PATCH 4/7] Add profile --- .gitignore | 2 +- backend/app.py | 5 +- backend/constants.py | 4 +- backend/db.py | 4 + backend/models.py | 2 + backend/requirements.txt | 4 +- backend/routes/messaging.py | 3 +- backend/routes/profile.py | 98 +++++++++++++++++++ frontend/index.html | 64 ++++++++----- frontend/src/auth.ts | 13 ++- frontend/src/chat.ts | 16 ++++ frontend/src/css/_chat.scss | 18 ++++ frontend/src/css/_profile.scss | 100 ++++++++++++++++++-- frontend/src/images/pesel.svg | 15 --- frontend/src/leftpanel.ts | 3 + frontend/src/notification.ts | 37 ++++++++ frontend/src/profile.ts | 88 ++++++----------- frontend/src/profile/README.md | 118 +++++++++++++++++++++++ frontend/src/profile/api.ts | 55 +++++++++++ frontend/src/profile/editor.ts | 85 +++++++++++++++++ frontend/src/profile/image-cropper.ts | 131 ++++++++++++++++++++++++++ frontend/src/profile/types.ts | 9 ++ frontend/src/profile/upload.ts | 120 +++++++++++++++++++++++ frontend/src/settings.ts | 1 - frontend/src/types.ts | 6 ++ 25 files changed, 885 insertions(+), 116 deletions(-) create mode 100644 backend/routes/profile.py delete mode 100644 frontend/src/images/pesel.svg create mode 100644 frontend/src/notification.ts create mode 100644 frontend/src/profile/README.md create mode 100644 frontend/src/profile/api.ts create mode 100644 frontend/src/profile/editor.ts create mode 100644 frontend/src/profile/image-cropper.ts create mode 100644 frontend/src/profile/types.ts create mode 100644 frontend/src/profile/upload.ts diff --git a/.gitignore b/.gitignore index f0cb9db..a7c6771 100644 --- a/.gitignore +++ b/.gitignore @@ -376,7 +376,7 @@ pyrightconfig.json # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) -instance +data .vite *.db package-lock.json \ No newline at end of file diff --git a/backend/app.py b/backend/app.py index 4476f9a..9b7a6ae 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from routes import account, messaging +from routes import account, messaging, profile # Инициализация FastAPI app = FastAPI(title="PixelChat") @@ -17,4 +17,5 @@ app.add_middleware( # Routes app.include_router(account.router) -app.include_router(messaging.router) \ No newline at end of file +app.include_router(messaging.router) +app.include_router(profile.router) \ No newline at end of file diff --git a/backend/constants.py b/backend/constants.py index d9a47c3..e3e8bb7 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -1,4 +1,4 @@ -DATABASE_URL = "sqlite:///./pixelchat.db" -JWT_SECRET_KEY = "pixelchat-jwt-secret" +DATABASE_URL = "sqlite:///./data/database.db" +JWT_SECRET_KEY = "fromchat-jwt-secret" JWT_ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_HOURS = 24 \ No newline at end of file diff --git a/backend/db.py b/backend/db.py index 834a613..1708283 100644 --- a/backend/db.py +++ b/backend/db.py @@ -1,6 +1,10 @@ +import os from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine from constants import DATABASE_URL +# Ensure data directory exists +os.makedirs("data", exist_ok=True) + engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index c193247..00e7f90 100644 --- a/backend/models.py +++ b/backend/models.py @@ -15,6 +15,7 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) username = Column(String(50), unique=True, nullable=False, index=True) password_hash = Column(String(200), nullable=False) + profile_picture = Column(String(255), nullable=True) online = Column(Boolean, default=False) last_seen = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now) @@ -56,6 +57,7 @@ class MessageResponse(BaseModel): is_author: bool is_read: bool username: str + profile_picture: str | None class Config: from_attributes = True diff --git a/backend/requirements.txt b/backend/requirements.txt index 158eacb..326b790 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,4 +3,6 @@ fastapi[standard]>=0.116.1 pydantic>=2.11.7 sqlalchemy>=2.0.43 bcrypt>=4.3.0 -websockets>=15.0.1 \ No newline at end of file +websockets>=15.0.1 +Pillow>=10.0.0 +python-multipart>=0.0.6 \ No newline at end of file diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 2e93e1b..9e2bafb 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -15,7 +15,8 @@ def convert_message(msg: Message) -> dict: "content": msg.content, "timestamp": msg.timestamp.isoformat(), "is_read": msg.is_read, - "username": msg.author.username + "username": msg.author.username, + "profile_picture": msg.author.profile_picture } async def get_messages_inner(db: Session): diff --git a/backend/routes/profile.py b/backend/routes/profile.py new file mode 100644 index 0000000..a82dfca --- /dev/null +++ b/backend/routes/profile.py @@ -0,0 +1,98 @@ +from pathlib import Path +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.orm import Session +from PIL import Image +import os +import uuid +import io + +from dependencies import get_db, get_current_user +from models import User + +router = APIRouter() + +# Create uploads directory if it doesn't exist +PROFILE_PICTURES_DIR = Path("data/uploads/pfp") + +os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True) + +@router.post("/upload-profile-picture") +async def upload_profile_picture( + profile_picture: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Upload and process a profile picture + """ + # Validate file type + if not profile_picture.content_type.startswith('image/'): + raise HTTPException(status_code=400, detail="File must be an image") + + # Validate file size (max 5MB) + if profile_picture.size > 5 * 1024 * 1024: + raise HTTPException(status_code=400, detail="File size must be less than 5MB") + + try: + # Read and process the image + image_data = await profile_picture.read() + + # Open image with PIL + image = Image.open(io.BytesIO(image_data)) + + # Convert to RGB if necessary + if image.mode != 'RGB': + image = image.convert('RGB') + + # Resize to a reasonable size (200x200) + image.thumbnail((200, 200), Image.Resampling.LANCZOS) + + # Generate unique filename + filename = f"{current_user.id}_{uuid.uuid4().hex}.jpg" + filepath = os.path.join(PROFILE_PICTURES_DIR, filename) + + # Save the processed image + image.save(filepath, 'JPEG', quality=85) + + # Update user's profile picture in database + profile_picture_url = f"/api/profile-picture/{filename}" + current_user.profile_picture = profile_picture_url + db.commit() + + return { + "message": "Profile picture uploaded successfully", + "profile_picture_url": profile_picture_url + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)}") + +@router.get("/profile-picture/{filename}") +async def get_profile_picture(filename: str): + """ + Serve profile picture files + """ + filepath = os.path.join(PROFILE_PICTURES_DIR, filename) + + if not os.path.exists(filepath): + raise HTTPException(status_code=404, detail="Profile picture not found") + + from fastapi.responses import FileResponse + return FileResponse(filepath, media_type="image/jpeg") + +@router.get("/user/profile") +async def get_user_profile( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Get current user's profile information + """ + return { + "id": current_user.id, + "username": current_user.username, + "profile_picture": current_user.profile_picture, + "online": current_user.online, + "last_seen": current_user.last_seen, + "created_at": current_user.created_at + } diff --git a/frontend/index.html b/frontend/index.html index dc6280e..93cd4aa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -193,19 +193,50 @@
-
-
- Ваше фото +
+
+
+ Ваше фото + + +
+
-
-
Loading...
+ + + +
+ Сохранить изменения + Закрыть +
- -

- Закрыть -

+ + + +
+
+

Обрезать фото профиля

+ +
+
+
+
+
+ Отмена + Сохранить +
+
+
+
@@ -215,8 +246,7 @@
- Профиль - Уведомления + Уведомления Внешний вид Безопасность Язык @@ -225,15 +255,7 @@ О приложении
-
-

Профиль

- - - - Сохранить изменения -
- -
+

Уведомления

Новые сообщения Звуковые уведомления @@ -288,7 +310,7 @@

О приложении

Версия: 1.0.0

-

© 2024 Boost Chat. Все права защищены.

+

© 2024 From Chat. Все права защищены.

Политика конфиденциальности Условия использования
diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts index d869a72..7a355da 100644 --- a/frontend/src/auth.ts +++ b/frontend/src/auth.ts @@ -1,4 +1,5 @@ import { loadMessages } from "./chat"; +import { initializeProfile } from "./profile"; import type { Headers, ErrorResponse, User, LoginResponse, LoginRequest, RegisterRequest } from "./types"; import { API_BASE_URL } from "./config"; @@ -8,10 +9,13 @@ export let authToken: string | null = null; // Helper function to get auth headers -export function getAuthHeaders(): Headers { - const headers: Headers = { - 'Content-Type': 'application/json', - }; +export function getAuthHeaders(json: boolean = true): Headers { + const headers: Headers = {}; + + if (json) { + headers["Content-Type"] = "application/json"; + } + if (authToken) { headers['Authorization'] = `Bearer ${authToken}`; } @@ -93,6 +97,7 @@ document.getElementById('login-form-element')!.addEventListener('submit', async currentUser = data.user; showChat(); loadMessages(); // Start loading messages + initializeProfile(); // Initialize profile after login } else { const data: ErrorResponse = await response.json(); showAlert('login-alerts', data.message || 'Неверное имя пользователя или пароль', 'danger'); diff --git a/frontend/src/chat.ts b/frontend/src/chat.ts index 1c71fc8..1d5f6d1 100644 --- a/frontend/src/chat.ts +++ b/frontend/src/chat.ts @@ -19,6 +19,22 @@ export function addMessage(message: Message, isAuthor: boolean) { const messageInner = document.createElement('div'); messageInner.classList.add('message-inner'); + // Add profile picture for received messages + if (!isAuthor) { + const profilePicDiv = document.createElement('div'); + profilePicDiv.classList.add('message-profile-pic'); + + const profileImg = document.createElement('img'); + profileImg.src = message.profile_picture || './src/images/default-avatar.png'; + profileImg.alt = message.username; + profileImg.onerror = () => { + profileImg.src = './src/images/default-avatar.png'; + }; + + profilePicDiv.appendChild(profileImg); + messageDiv.appendChild(profilePicDiv); + } + if (!isAuthor) { const usernameDiv = document.createElement('div'); usernameDiv.classList.add('message-username'); diff --git a/frontend/src/css/_chat.scss b/frontend/src/css/_chat.scss index fe4ecdf..0f143f3 100644 --- a/frontend/src/css/_chat.scss +++ b/frontend/src/css/_chat.scss @@ -151,6 +151,23 @@ max-width: 70%; position: relative; width: max-content; + display: flex; + align-items: flex-end; + gap: 8px; + + .message-profile-pic { + width: 32px; + height: 32px; + flex-shrink: 0; + margin-bottom: 4px; + + img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + } + } .message-inner { padding: 0.8rem 1rem; @@ -174,6 +191,7 @@ &.sent { margin-left: auto; + flex-direction: row-reverse; .message-inner { background-color: $color-dark-primary-container; diff --git a/frontend/src/css/_profile.scss b/frontend/src/css/_profile.scss index deae3bb..76fe006 100644 --- a/frontend/src/css/_profile.scss +++ b/frontend/src/css/_profile.scss @@ -2,33 +2,117 @@ @use "common/material" as *; #profile-dialog { + .profile-dialog-content { + display: flex; + flex-direction: column; + gap: 24px; + min-width: 400px; + } + .header-top { display: flex; flex-direction: row; + align-items: center; gap: 16px; position: relative; + padding-bottom: 16px; + border-bottom: 1px solid $color-dark-outline; - .left { + .profile-picture-container { + position: relative; $size: 70px; - width: $size; height: $size; + flex-shrink: 0; #profile-picture { width: $size; height: $size; border-radius: 50%; + object-fit: cover; + } + + .upload-overlay { + position: absolute; + bottom: 0; + right: 0; + width: 28px; + height: 28px; + cursor: pointer; } } - .right { - display: flex; - flex-direction: column; - justify-content: center; + mdui-text-field { + flex: 1; + } + } - #profile-username { - font-size: larger; + #profile-form { + display: flex; + flex-direction: column; + gap: 16px; + + mdui-text-field { + width: 100%; + } + + .dialog-actions { + display: flex; + gap: 12px; + padding-top: 16px; + border-top: 1px solid $color-dark-outline; + + > * { + flex: 1; } } } +} + +// Cropper Dialog Styles +#cropper-dialog { + .cropper-dialog-content { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 500px; + max-width: 600px; + } + + .cropper-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 16px; + border-bottom: 1px solid $color-dark-outline; + + h3 { + margin: 0; + color: $color-dark-on-surface; + } + } + + .cropper-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; + background: $color-dark-surface-container; + border-radius: 8px; + overflow: hidden; + + #cropper-area { + width: 100%; + height: 100%; + min-height: 400px; + } + } + + .cropper-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 16px; + border-top: 1px solid $color-dark-outline; + } } \ No newline at end of file diff --git a/frontend/src/images/pesel.svg b/frontend/src/images/pesel.svg deleted file mode 100644 index 7290bcc..0000000 --- a/frontend/src/images/pesel.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/src/leftpanel.ts b/frontend/src/leftpanel.ts index 9115422..e7fa548 100644 --- a/frontend/src/leftpanel.ts +++ b/frontend/src/leftpanel.ts @@ -1,4 +1,5 @@ import type { Dialog } from "mdui/components/dialog"; +import { loadProfilePicture } from "./profile/upload"; // сварачивание и разворачивание чата const but = document.getElementById('chat-recrol')!; @@ -29,6 +30,8 @@ const dialogClose = document.getElementById("profile-dialog-close")!; butprofile.addEventListener('click', () => { dialog.open = true; + // Load profile picture when dialog opens + loadProfilePicture(); }); dialogClose.addEventListener("click", () => { diff --git a/frontend/src/notification.ts b/frontend/src/notification.ts new file mode 100644 index 0000000..831e0a4 --- /dev/null +++ b/frontend/src/notification.ts @@ -0,0 +1,37 @@ +export type NotificationType = 'success' | 'error'; + +function showNotification(message: string, type: NotificationType): void { + const notification = document.createElement('div'); + notification.textContent = message; + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + padding: 12px 16px; + border-radius: 4px; + color: white; + background: ${type === 'success' ? '#4caf50' : '#f44336'}; + z-index: 10000; + font-family: inherit; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + transition: opacity 0.3s ease; + `; + + document.body.appendChild(notification); + + // Fade out and remove + setTimeout(() => { + notification.style.opacity = '0'; + setTimeout(() => { + notification.remove(); + }, 300); + }, 3000); +} + +export function showSuccess(message: string): void { + showNotification(message, 'success'); +} + +export function showError(message: string): void { + showNotification(message, 'error'); +} diff --git a/frontend/src/profile.ts b/frontend/src/profile.ts index 0377d9e..cbcf271 100644 --- a/frontend/src/profile.ts +++ b/frontend/src/profile.ts @@ -1,63 +1,31 @@ -// const fileInput = document.getElementById('fileInput') as HTMLInputElement; -// const preview = document.getElementById('preview') as HTMLImageElement; -// const preview1 = document.getElementById('preview1') as HTMLImageElement; -// const button = document.getElementById('uploadButton')!; +import type { Dialog } from "mdui/components/dialog"; +import { loadProfileData } from './profile/editor'; +import { loadProfilePicture, initializeProfileUpload } from "./profile/upload"; +import { initializeProfileEditor } from './profile/editor'; -// if (fileInput && preview && button) { -// // при клике по кнопке откроем диалог выбора файла -// button.addEventListener('click', () => { -// fileInput.click(); -// }); +// Handle profile form submission +const form = document.getElementById("profile-form")!; +const dialog = document.getElementById("profile-dialog") as Dialog; -// // при выборе файла — показываем его -// fileInput.addEventListener('change', () => { -// const file: File | null = fileInput.files ? fileInput.files[0] : null; -// if (file) { -// const reader: FileReader = new FileReader(); -// reader.onload = (e: ProgressEvent) => { -// if (e.target && typeof e.target.result === 'string') { -// preview.src = e.target.result; -// preview1.src = e.target.result; -// preview.style.display = 'block'; -// } -// }; -// reader.readAsDataURL(file); -// } -// }); -// } +form.addEventListener("submit", async (e) => { + e.preventDefault(); + + // TODO: Process form data if needed + // For now, just close the dialog + dialog.open = false; +}); -// const textDiv = document.getElementById('text-nik')!; -// const input = document.getElementById('nik') as HTMLInputElement; -// const button1 = document.getElementById('change-name')!; - -// button1.addEventListener('click', () => { -// if (input.style.display === 'none') { -// // Переводим в режим редактирования -// input.value = textDiv.textContent || ''; -// textDiv.style.display = 'none'; -// input.style.display = 'flex'; -// } else { -// // Сохраняем изменения -// textDiv.textContent = input.value; -// textDiv.style.display = 'flex'; -// input.style.display = 'none'; -// } -// }); - -// const textDiv2 = document.getElementById('text-discr')!; -// const input2 = document.getElementById('discr') as HTMLInputElement; -// const button2 = document.getElementById('change-discription')!; - -// button2.addEventListener('click', () => { -// if (input2.style.display === 'none') { -// // Переводим в режим редактирования -// input2.value = textDiv2.textContent || ''; -// textDiv2.style.display = 'none'; -// input2.style.display = 'flex'; -// } else { -// // Сохраняем изменения -// textDiv2.textContent = input2.value; -// textDiv2.style.display = 'flex'; -// input2.style.display = 'none'; -// } -// }); \ No newline at end of file +// Initialize profile functionality after login +export function initializeProfile(): void { + // Initialize profile modules + initializeProfileUpload(); + initializeProfileEditor(); + + // Load profile data + Promise.all([ + loadProfilePicture(), + loadProfileData() + ]).catch(error => { + console.error('Error initializing profile:', error); + }); +} \ No newline at end of file diff --git a/frontend/src/profile/README.md b/frontend/src/profile/README.md new file mode 100644 index 0000000..67aabce --- /dev/null +++ b/frontend/src/profile/README.md @@ -0,0 +1,118 @@ +# Profile Module Structure + +This directory contains the modularized profile functionality for the FromChat application. + +## Structure + +``` +profile/ +├── types.ts # Type definitions +├── notification.ts # Notification system +├── image-cropper.ts # Image cropping functionality +├── profile-service.ts # API service layer +├── profile-upload.ts # Upload management +├── profile-editor.ts # Profile editing functionality +└── README.md # This file +``` + +## Modules + +### `types.ts` +Contains all TypeScript interfaces and types used across the profile module: +- `ProfileData` - User profile data structure +- `UploadResponse` - API response for uploads +- `NotificationType` - Notification types +- `CropPosition` - Image cropping position +- `DragStart` - Drag operation start position + +### `notification.ts` +Top-level notification functions for displaying success/error messages: +- `showSuccess(message: string)` - Show success notification +- `showError(message: string)` - Show error notification + +### `image-cropper.ts` +Canvas-based image cropper for profile pictures: +- `ImageCropper` - Main cropper class with touch/mouse support +- Handles circular cropping with drag functionality + +### `profile-service.ts` +API service layer for profile operations: +- `loadProfile()` - Load user profile data +- `uploadProfilePicture(file: Blob)` - Upload profile picture +- `updateProfile(data: Partial)` - Update profile data + +### `profile-upload.ts` +Manages profile picture upload workflow: +- `loadProfilePicture()` - Load and display profile picture +- Global variables and event listeners for upload UI +- Handles file selection, cropping, and upload + +### `profile-editor.ts` +Manages profile text editing (nickname, description): +- `loadProfileData()` - Load profile text data +- `setNicknameValue(value: string)` - Set nickname value +- `setDescriptionValue(value: string)` - Set description value +- `getNicknameValue()` - Get current nickname +- `getDescriptionValue()` - Get current description +- Global event listeners for edit buttons +- Supports Enter to save, Escape to cancel + +## Usage + +### Direct Imports +```typescript +// Import specific functions from each module +import { loadProfile, uploadProfilePicture, updateProfile } from './profile/profile-service'; +import { loadProfilePicture } from './profile/profile-upload'; +import { loadProfileData, setNicknameValue } from './profile/profile-editor'; +import { showSuccess, showError } from './profile/notification'; +import { ImageCropper } from './profile/image-cropper'; +``` + +### Loading Profile Data +```typescript +// Load profile picture +await loadProfilePicture(); + +// Load profile text data +await loadProfileData(); +``` + +### Uploading Profile Picture +The upload process is handled automatically by the global event listeners in `profile-upload.ts` when users interact with the upload UI. + +### Editing Profile Text +The editing process is handled automatically by the global event listeners in `profile-editor.ts` when users interact with the edit buttons. + +### Showing Notifications +```typescript +showSuccess('Operation completed successfully!'); +showError('Something went wrong!'); +``` + +### API Operations +```typescript +// Load profile data +const profileData = await loadProfile(); + +// Update profile +const success = await updateProfile({ nickname: 'New Name' }); + +// Upload profile picture +const result = await uploadProfilePicture(blob); +``` + +## Benefits of This Structure + +1. **Separation of Concerns**: Each module has a single responsibility +2. **Reusability**: Functions can be used independently +3. **Maintainability**: Easier to find and fix issues +4. **Testability**: Each function can be tested in isolation +5. **Type Safety**: Strong TypeScript typing throughout +6. **Top-level Functions**: Simple function calls instead of class instances +7. **Direct Imports**: Import only what you need from specific modules +8. **No Index File**: Direct imports reduce complexity and improve tree shaking + +## Migration from Original Files + +The original `profile.ts` and `profile-upload.ts` files have been refactored to use this modular structure. The functionality remains the same, but it's now better organized and more maintainable. diff --git a/frontend/src/profile/api.ts b/frontend/src/profile/api.ts new file mode 100644 index 0000000..7b5ba4e --- /dev/null +++ b/frontend/src/profile/api.ts @@ -0,0 +1,55 @@ +import { getAuthHeaders } from '../auth'; +import type { ProfileData, UploadResponse } from './types'; + +export async function loadProfile(): Promise { + try { + const response = await fetch('/api/user/profile', { + headers: getAuthHeaders() + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error loading profile:', error); + return null; + } +} + +export async function uploadProfilePicture(file: Blob): Promise { + try { + const formData = new FormData(); + formData.append('profile_picture', file, 'profile_picture.jpg'); + + const response = await fetch('/api/upload-profile-picture', { + method: 'POST', + body: formData, + headers: getAuthHeaders(false) + }); + + if (response.ok) { + return await response.json(); + } + return null; + } catch (error) { + console.error('Upload error:', error); + return null; + } +} + +export async function updateProfile(data: Partial): Promise { + try { + const response = await fetch('/api/user/profile', { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify(data) + }); + + return response.ok; + } catch (error) { + console.error('Error updating profile:', error); + return false; + } +} diff --git a/frontend/src/profile/editor.ts b/frontend/src/profile/editor.ts new file mode 100644 index 0000000..40422ff --- /dev/null +++ b/frontend/src/profile/editor.ts @@ -0,0 +1,85 @@ +import { updateProfile } from './api'; +import { loadProfile } from './api'; +import { showSuccess, showError } from '../notification'; +import type { TextField } from 'mdui/components/text-field'; + +// DOM elements +let profileForm: HTMLElement; +let nicknameField: TextField; // MDUI TextField +let descriptionField: TextField; // MDUI TextField +let isInitialized = false; + +export function setUsernameValue(value: string): void { + if (nicknameField && nicknameField.value !== undefined) { + nicknameField.value = value; + } +} + +export function setDescriptionValue(value: string): void { + if (descriptionField && descriptionField.value !== undefined) { + descriptionField.value = value; + } +} + +export function getUsernameValue(): string { + if (nicknameField && nicknameField.value !== undefined) { + return nicknameField.value; + } + return ''; +} + +export function getDescriptionValue(): string { + if (descriptionField && descriptionField.value !== undefined) { + return descriptionField.value; + } + return ''; +} + +export async function loadProfileData(): Promise { + const userData = await loadProfile(); + if (userData) { + if (userData.nickname) { + setUsernameValue(userData.nickname); + } + if (userData.description) { + setDescriptionValue(userData.description); + } + } +} + +// Setup form submission handler +function setupFormHandler(): void { + if (isInitialized) return; + + // Get DOM elements + profileForm = document.getElementById('profile-form')!; + nicknameField = document.getElementById('username-field') as any; // MDUI TextField + descriptionField = document.getElementById('description-field') as any; // MDUI TextField + + profileForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const nickname = getUsernameValue(); + const description = getDescriptionValue(); + + if (nickname || description) { + const success = await updateProfile({ + nickname: nickname || undefined, + description: description || undefined + }); + + if (success) { + showSuccess('Профиль обновлен!'); + } else { + showError('Ошибка при обновлении профиля'); + } + } + }); + + isInitialized = true; +} + +// Initialize editor functionality +export function initializeProfileEditor(): void { + setupFormHandler(); +} diff --git a/frontend/src/profile/image-cropper.ts b/frontend/src/profile/image-cropper.ts new file mode 100644 index 0000000..45ac17c --- /dev/null +++ b/frontend/src/profile/image-cropper.ts @@ -0,0 +1,131 @@ +import type { Size2D } from "../types"; + +export class ImageCropper { + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private image!: HTMLImageElement; + private cropSize: number = 200; + private isDragging: boolean = false; + private dragStart: Size2D = { x: 0, y: 0 }; + private cropPosition: Size2D = { x: 0, y: 0 }; + + constructor(container: HTMLElement) { + this.canvas = document.createElement('canvas'); + this.canvas.width = this.cropSize; + this.canvas.height = this.cropSize; + this.ctx = this.canvas.getContext('2d')!; + + container.appendChild(this.canvas); + this.setupEventListeners(); + } + + private setupEventListeners(): void { + this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this)); + this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this)); + this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this)); + this.canvas.addEventListener('touchstart', this.onTouchStart.bind(this)); + this.canvas.addEventListener('touchmove', this.onTouchMove.bind(this)); + this.canvas.addEventListener('touchend', this.onTouchEnd.bind(this)); + } + + private onMouseDown(e: MouseEvent): void { + this.isDragging = true; + this.dragStart = { x: e.clientX, y: e.clientY }; + } + + private onMouseMove(e: MouseEvent): void { + if (!this.isDragging) return; + + const deltaX = e.clientX - this.dragStart.x; + const deltaY = e.clientY - this.dragStart.y; + + this.cropPosition.x += deltaX; + this.cropPosition.y += deltaY; + + this.dragStart = { x: e.clientX, y: e.clientY }; + this.render(); + } + + private onMouseUp(): void { + this.isDragging = false; + } + + private onTouchStart(e: TouchEvent): void { + e.preventDefault(); + const touch = e.touches[0]; + this.isDragging = true; + this.dragStart = { x: touch.clientX, y: touch.clientY }; + } + + private onTouchMove(e: TouchEvent): void { + e.preventDefault(); + if (!this.isDragging) return; + + const touch = e.touches[0]; + const deltaX = touch.clientX - this.dragStart.x; + const deltaY = touch.clientY - this.dragStart.y; + + this.cropPosition.x += deltaX; + this.cropPosition.y += deltaY; + + this.dragStart = { x: touch.clientX, y: touch.clientY }; + this.render(); + } + + private onTouchEnd(): void { + this.isDragging = false; + } + + loadImage(file: File): Promise { + return new Promise((resolve) => { + this.image = new Image(); + this.image.onload = () => { + this.render(); + resolve(); + }; + this.image.src = URL.createObjectURL(file); + }); + } + + private render(): void { + if (!this.image) return; + + // Clear canvas + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + // Calculate crop area + const scale = Math.max(this.cropSize / this.image.width, this.cropSize / this.image.height); + const scaledWidth = this.image.width * scale; + const scaledHeight = this.image.height * scale; + + // Draw image + this.ctx.save(); + this.ctx.globalCompositeOperation = 'source-over'; + this.ctx.drawImage( + this.image, + this.cropPosition.x, + this.cropPosition.y, + scaledWidth, + scaledHeight + ); + this.ctx.restore(); + + // Draw crop overlay + this.ctx.save(); + this.ctx.globalCompositeOperation = 'destination-in'; + this.ctx.beginPath(); + this.ctx.arc(this.cropSize / 2, this.cropSize / 2, this.cropSize / 2, 0, 2 * Math.PI); + this.ctx.fill(); + this.ctx.restore(); + } + + getCroppedImage(): string { + return this.canvas.toDataURL('image/jpeg', 0.8); + } + + destroy(): void { + if (this.canvas.parentNode) { + this.canvas.parentNode.removeChild(this.canvas); + } + } +} diff --git a/frontend/src/profile/types.ts b/frontend/src/profile/types.ts new file mode 100644 index 0000000..7a9aa34 --- /dev/null +++ b/frontend/src/profile/types.ts @@ -0,0 +1,9 @@ +export interface ProfileData { + profile_picture?: string; + nickname?: string; + description?: string; +} + +export interface UploadResponse { + profile_picture_url: string; +} \ No newline at end of file diff --git a/frontend/src/profile/upload.ts b/frontend/src/profile/upload.ts new file mode 100644 index 0000000..889e5c4 --- /dev/null +++ b/frontend/src/profile/upload.ts @@ -0,0 +1,120 @@ +import type { Dialog } from "mdui/components/dialog"; +import { ImageCropper } from './image-cropper'; +import { uploadProfilePicture } from './api'; +import { loadProfile } from './api'; +import { showSuccess, showError } from '../notification'; + +// Global variables +let cropper: ImageCropper | null = null; +let isInitialized = false; + +// DOM elements +let cropperDialog: Dialog; +let fileInput: HTMLInputElement; +let uploadBtn: HTMLElement; +let cropSaveBtn: HTMLElement; +let cropCancelBtn: HTMLElement; +let cropperCloseBtn: HTMLElement; +let cropperArea: HTMLElement; + +// Setup event listeners +function setupEventListeners(): void { + if (isInitialized) return; + + // Get DOM elements + cropperDialog = document.getElementById('cropper-dialog') as Dialog; + fileInput = document.getElementById('pfp-file-input') as HTMLInputElement; + uploadBtn = document.getElementById('upload-pfp-btn')!; + cropSaveBtn = document.getElementById('crop-save')!; + cropCancelBtn = document.getElementById('crop-cancel')!; + cropperCloseBtn = document.getElementById('cropper-close')!; + cropperArea = document.getElementById('cropper-area')!; + + uploadBtn.addEventListener('click', () => { + fileInput.click(); + }); + + fileInput.addEventListener('change', (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + openCropper(file); + } + }); + + cropSaveBtn.addEventListener('click', () => { + saveCroppedImage(); + }); + + cropCancelBtn.addEventListener('click', () => { + closeCropper(); + }); + + cropperCloseBtn.addEventListener('click', () => { + closeCropper(); + }); + + isInitialized = true; +} + +async function openCropper(file: File): Promise { + // Clear previous cropper + cropperArea.innerHTML = ''; + + // Create new cropper + cropper = new ImageCropper(cropperArea); + + // Load image + await cropper.loadImage(file); + + // Open dialog + cropperDialog.open = true; +} + +function closeCropper(): void { + cropperDialog.open = false; + cropperArea.innerHTML = ''; + if (cropper) { + cropper.destroy(); + cropper = null; + } + fileInput.value = ''; +} + +async function saveCroppedImage(): Promise { + if (!cropper) return; + + const croppedImageData = cropper.getCroppedImage(); + + // Convert data URL to blob + const response = await fetch(croppedImageData); + const blob = await response.blob(); + + const result = await uploadProfilePicture(blob); + + if (result) { + // Update profile picture display + const profilePicture = document.getElementById('profile-picture') as HTMLImageElement; + profilePicture.src = result.profile_picture_url + '?t=' + Date.now(); // Cache bust + + // Close cropper + closeCropper(); + + // Show success message + showSuccess('Фото профиля обновлено!'); + } else { + showError('Ошибка при загрузке фото'); + } +} + +export async function loadProfilePicture(): Promise { + const userData = await loadProfile(); + if (userData?.profile_picture) { + const profilePicture = document.getElementById('profile-picture') as HTMLImageElement; + profilePicture.src = userData.profile_picture + '?t=' + Date.now(); // Cache bust + } +} + +// Initialize upload functionality +export function initializeProfileUpload(): void { + setupEventListeners(); +} diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index c575858..e792714 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -15,7 +15,6 @@ function initializeSettings() { // Create a mapping between list items and their corresponding panels const panelMapping = { - 'Профиль': 'profile-settings', 'Уведомления': 'notifications-settings', 'Внешний вид': 'appearance-settings', 'Безопасность': 'security-settings', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d341be8..acb61c5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4,6 +4,11 @@ export interface ErrorResponse { message: string; } +export interface Size2D { + x: number; + y: number; +} + // App types export interface Message { @@ -12,6 +17,7 @@ export interface Message { content: string; is_read: boolean; timestamp: string; + profile_picture?: string; } export interface Messages { From eb85e68fc21f844e0ed16d0a416bea56fa008e88 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 22 Aug 2025 22:01:51 +0300 Subject: [PATCH 5/7] Add documentation --- .cursor/rules/docs.mdc | 11 ++ frontend/index.html | 15 +- frontend/src/auth.ts | 152 +++++++++++++++--- frontend/src/chat.ts | 36 ++++- frontend/src/config.ts | 24 +++ frontend/src/init.ts | 8 +- frontend/src/leftpanel.ts | 93 +++++++---- frontend/src/links.ts | 11 -- frontend/src/main.ts | 10 +- frontend/src/profile.ts | 15 +- frontend/src/profile/README.md | 118 -------------- frontend/src/profile/api.ts | 47 ++++++ frontend/src/profile/editor.ts | 122 +++++++++++---- frontend/src/profile/image-cropper.ts | 107 +++++++++++++ frontend/src/profile/types.ts | 19 +++ frontend/src/profile/upload.ts | 187 ++++++++++++++--------- frontend/src/settings.ts | 125 +++++++++------ frontend/src/types.ts | 90 ++++++++++- frontend/src/utils.ts | 12 -- frontend/src/{ => utils}/material.ts | 7 + frontend/src/{ => utils}/notification.ts | 32 ++++ frontend/src/utils/utils.ts | 33 ++++ frontend/src/websocket.ts | 21 ++- 23 files changed, 931 insertions(+), 364 deletions(-) create mode 100644 .cursor/rules/docs.mdc delete mode 100644 frontend/src/links.ts delete mode 100644 frontend/src/profile/README.md delete mode 100644 frontend/src/utils.ts rename frontend/src/{ => utils}/material.ts (76%) rename frontend/src/{ => utils}/notification.ts (55%) create mode 100644 frontend/src/utils/utils.ts diff --git a/.cursor/rules/docs.mdc b/.cursor/rules/docs.mdc new file mode 100644 index 0000000..4b13faf --- /dev/null +++ b/.cursor/rules/docs.mdc @@ -0,0 +1,11 @@ +--- +description: Documentation rules +alwaysApply: false +--- + +When documenting this project, follow these rules: + +1. For TS, use JSDoc. +2. When something is self-explanatory or is a constant for a HTML element, don't document it. +3. Do NOT change the structure of the code, only add documentation. +4. When filling in the author field, say that you are Cursor. \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 93cd4aa..17d1d18 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -120,7 +120,7 @@
Loading...
@@ -139,10 +139,10 @@ - + - + @@ -159,17 +159,18 @@
-
+
Avatar
-

Общий чат

+

Общий чат

- Онлайн + + Онлайн

- Свернуть чат + Свернуть чат
diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts index 7a355da..98a01ec 100644 --- a/frontend/src/auth.ts +++ b/frontend/src/auth.ts @@ -1,14 +1,36 @@ +/** + * @fileoverview Authentication system implementation + * @description Handles user authentication, registration, and session management + * @author Cursor + * @version 1.0.0 + */ + import { loadMessages } from "./chat"; import { initializeProfile } from "./profile"; import type { Headers, ErrorResponse, User, LoginResponse, LoginRequest, RegisterRequest } from "./types"; import { API_BASE_URL } from "./config"; -// Authentication and navigation handling +/** + * Current authenticated user information + * @type {User | null} + */ export let currentUser: User | null = null; + +/** + * JWT authentication token + * @type {string | null} + */ export let authToken: string | null = null; - -// Helper function to get auth headers +/** + * Generates authentication headers for API requests + * @param {boolean} json - Whether to include JSON content type header + * @returns {Headers} Headers object with authentication and content type + * @function getAuthHeaders + * @example + * const headers = getAuthHeaders(); + * fetch('/api/endpoint', { headers }); + */ export function getAuthHeaders(json: boolean = true): Headers { const headers: Headers = {}; @@ -22,38 +44,65 @@ export function getAuthHeaders(json: boolean = true): Headers { return headers; } -// Show login form -export function showLogin() { +/** + * Shows the login form and hides other interfaces + * @function showLogin + * @example + * showLogin(); + */ +export function showLogin(): void { document.getElementById('login-form')!.style.display = 'flex'; document.getElementById('register-form')!.style.display = 'none'; document.getElementById('chat-interface')!.style.display = 'none'; clearAlerts(); } -// Show register form -export function showRegister() { +/** + * Shows the registration form and hides other interfaces + * @function showRegister + * @example + * showRegister(); + */ +export function showRegister(): void { document.getElementById('login-form')!.style.display = 'none'; document.getElementById('register-form')!.style.display = 'flex'; document.getElementById('chat-interface')!.style.display = 'none'; clearAlerts(); } -// Show chat interface -export function showChat() { +/** + * Shows the chat interface and hides authentication forms + * @function showChat + * @example + * showChat(); + */ +export function showChat(): void { document.getElementById('login-form')!.style.display = 'none'; document.getElementById('register-form')!.style.display = 'none'; document.getElementById('chat-interface')!.style.display = 'block'; loadMessages(); } -// Clear all alerts -export function clearAlerts() { +/** + * Clears all alert messages from authentication forms + * @function clearAlerts + * @private + */ +export function clearAlerts(): void { document.getElementById('login-alerts')!.innerHTML = ''; document.getElementById('register-alerts')!.innerHTML = ''; } -// Show alert message -export function showAlert(containerId: string, message: string, type: "success" | "danger" = 'danger') { +/** + * Shows an alert message in the specified container + * @param {string} containerId - ID of the container to show the alert in + * @param {string} message - Alert message to display + * @param {'success' | 'danger'} type - Type of alert (success or danger) + * @function showAlert + * @example + * showAlert('login-alerts', 'Login successful!', 'success'); + */ +export function showAlert(containerId: string, message: string, type: "success" | "danger" = 'danger'): void { const container = document.getElementById(containerId)!; const alertDiv = document.createElement('div'); alertDiv.className = `alert alert-${type}`; @@ -61,8 +110,14 @@ export function showAlert(containerId: string, message: string, type: "success" container.appendChild(alertDiv); } -// Handle login form submission -document.getElementById('login-form-element')!.addEventListener('submit', async (e) => { +/** + * Handles login form submission + * @async + * @function handleLogin + * @param {Event} e - Form submission event + * @private + */ +async function handleLogin(e: Event): Promise { e.preventDefault(); const usernameElement = document.getElementById('login-username') as HTMLInputElement; @@ -105,10 +160,16 @@ document.getElementById('login-form-element')!.addEventListener('submit', async } catch (error) { showAlert('login-alerts', 'Ошибка соединения с сервером', 'danger'); } -}); +} -// Handle register form submission -document.getElementById('register-form-element')!.addEventListener('submit', async (e) => { +/** + * Handles registration form submission + * @async + * @function handleRegister + * @param {Event} e - Form submission event + * @private + */ +async function handleRegister(e: Event): Promise { e.preventDefault(); const usernameElement = document.getElementById('register-username') as HTMLInputElement; @@ -167,10 +228,16 @@ document.getElementById('register-form-element')!.addEventListener('submit', asy } catch (error) { showAlert('register-alerts', 'Ошибка соединения с сервером', 'danger'); } -}); +} -// Handle logout -export async function logout() { +/** + * Logs out the current user and clears session data + * @async + * @function logout + * @example + * await logout(); + */ +export async function logout(): Promise { try { await fetch(`${API_BASE_URL}/logout`, { method: 'GET', @@ -186,15 +253,50 @@ export async function logout() { clearAlerts(); } -// Load chat interface -export function loadChat() { +/** + * Loads the chat interface and initializes messaging + * @function loadChat + * @example + * loadChat(); + */ +export function loadChat(): void { showChat(); loadMessages(); } -// Check authentication status on page load -export async function checkAuthStatus() { +/** + * Checks authentication status on page load + * @async + * @function checkAuthStatus + * @example + * await checkAuthStatus(); + */ +export async function checkAuthStatus(): Promise { // For JWT, we don't have a persistent token on page load // So we'll just show the login form showLogin(); } + +/** + * Sets up authentication form event listeners + * @function setupAuthForms + * @private + */ +function setupAuthForms(): void { + document.getElementById('login-form-element')!.addEventListener('submit', handleLogin); + document.getElementById('register-form-element')!.addEventListener('submit', handleRegister); +} + +/** + * Initializes links + * @function setupLinks + * @private + */ +function setupLinks(): void { + document.getElementById("login-link")!.addEventListener("click", showLogin); + document.getElementById("register-link")!.addEventListener("click", showRegister); +} + +// Initialize authentication forms +setupAuthForms(); +setupLinks(); \ No newline at end of file diff --git a/frontend/src/chat.ts b/frontend/src/chat.ts index 1d5f6d1..85502e2 100644 --- a/frontend/src/chat.ts +++ b/frontend/src/chat.ts @@ -1,11 +1,25 @@ +/** + * @fileoverview Chat functionality and message management + * @description Handles message display, loading, sending, and real-time updates + * @author Cursor + * @version 1.0.0 + */ + import { getAuthHeaders, currentUser, authToken } from "./auth"; import { API_BASE_URL } from "./config"; import { websocket } from "./websocket"; import type { Message, Messages, WebSocketMessage } from "./types"; -import { formatTime } from "./utils"; +import { formatTime } from "./utils/utils"; - -export function addMessage(message: Message, isAuthor: boolean) { +/** + * Adds a new message to the chat interface + * @param {Message} message - Message object to display + * @param {boolean} isAuthor - Whether the current user is the message author + * @function addMessage + * @example + * addMessage(messageData, messageData.username === currentUser.username); + */ +export function addMessage(message: Message, isAuthor: boolean): void { const messagesContainer = document.querySelector('.chat-messages') as HTMLElement; const messageDiv = document.createElement('div'); messageDiv.classList.add("message"); @@ -64,7 +78,13 @@ export function addMessage(message: Message, isAuthor: boolean) { messagesContainer.scrollTop = messagesContainer.scrollHeight; } -export function loadMessages() { +/** + * Loads chat messages from the server + * @function loadMessages + * @example + * loadMessages(); + */ +export function loadMessages(): void { fetch(`${API_BASE_URL}/get_messages`, { headers: getAuthHeaders() }) @@ -89,7 +109,13 @@ export function loadMessages() { }); } -export function sendMessage() { +/** + * Sends a message via WebSocket + * @function sendMessage + * @example + * sendMessage(); + */ +export function sendMessage(): void { const input = document.querySelector('.message-input') as HTMLInputElement; const message = input.value.trim(); diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 5de6c3e..f46c8a0 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -1,3 +1,27 @@ +/** + * @fileoverview Application configuration constants + * @description Contains all configuration values used throughout the application + * @author Cursor + * @version 1.0.0 + */ + +/** + * Base API endpoint for all backend requests + * @type {string} + * @constant + */ export const API_BASE_URL: string = '/api'; + +/** + * Full API URL including hostname and port for WebSocket connections + * @type {string} + * @constant + */ export const API_FULL_BASE_URL: string = `${location.hostname}:8301/api`; + +/** + * Application name displayed in UI and document title + * @type {string} + * @constant + */ export const PRODUCT_NAME: string = "FromChat"; \ No newline at end of file diff --git a/frontend/src/init.ts b/frontend/src/init.ts index 87305c2..47d1fe1 100644 --- a/frontend/src/init.ts +++ b/frontend/src/init.ts @@ -1,7 +1,13 @@ +/** + * @fileoverview Application initialization logic + * @description Handles initial application setup and state + * @author FromChat Team + * @version 1.0.0 + */ + import { showLogin } from "./auth"; import { PRODUCT_NAME } from "./config"; showLogin(); - document.getElementById("productname")!.textContent = PRODUCT_NAME; document.title = PRODUCT_NAME; \ No newline at end of file diff --git a/frontend/src/leftpanel.ts b/frontend/src/leftpanel.ts index e7fa548..62806b1 100644 --- a/frontend/src/leftpanel.ts +++ b/frontend/src/leftpanel.ts @@ -1,39 +1,70 @@ +/** + * @fileoverview Left panel UI controls and interactions + * @description Handles chat collapse/expand, chat switching, and profile dialog + * @author Cursor + * @version 1.0.0 + */ + import type { Dialog } from "mdui/components/dialog"; import { loadProfilePicture } from "./profile/upload"; // сварачивание и разворачивание чата -const but = document.getElementById('chat-recrol')!; -const but_list1 = document.getElementById('chat1but')!; -const but_list2 = document.getElementById('chat1but2')!; -const cont1 = document.getElementById('conteinerchat')!; -const namechat = document.getElementById('namechat')!; - -but.addEventListener('click', () => { - but.style.display = 'none'; - cont1.style.display = 'none'; -}); -but_list1.addEventListener('click', () => { - but.style.display = 'flex'; - cont1.style.display = 'flex'; - namechat.textContent = 'общий чат'; -}); -but_list2.addEventListener('click', () => { - but.style.display = 'flex'; - cont1.style.display = 'flex'; - namechat.textContent = 'общий чат 2'; -}); - -// открытие профиля -const butprofile = document.getElementById('profbut')!; +const chatCollapseBtn = document.getElementById('hide-chat')!; +const chat1 = document.getElementById('chat-list-chat-1')!; +const chat2 = document.getElementById('chat-list-chat-2')!; +const chatInner = document.getElementById('chat-inner')!; +const chatName = document.getElementById('chat-name')!; +const profileButton = document.getElementById('profile-open')!; const dialog = document.getElementById("profile-dialog") as Dialog; const dialogClose = document.getElementById("profile-dialog-close")!; -butprofile.addEventListener('click', () => { - dialog.open = true; - // Load profile picture when dialog opens - loadProfilePicture(); -}); +/** + * Sets up chat collapse functionality + * @function setupChatCollapse + * @private + */ +function setupChatCollapse(): void { + chatCollapseBtn.addEventListener('click', () => { + chatCollapseBtn.style.display = 'none'; + chatInner.style.display = 'none'; + }); +} -dialogClose.addEventListener("click", () => { - dialog.open = false; -}); \ No newline at end of file +/** + * Sets up chat switching functionality + * @function setupChatSwitching + * @private + */ +function setupChatSwitching(): void { + chat1.addEventListener('click', () => { + chatCollapseBtn.style.display = 'flex'; + chatInner.style.display = 'flex'; + chatName.textContent = 'общий чат'; + }); + + chat2.addEventListener('click', () => { + chatCollapseBtn.style.display = 'flex'; + chatInner.style.display = 'flex'; + chatName.textContent = 'общий чат 2'; + }); +} + +/** + * Sets up profile dialog functionality + * @function setupProfileDialog + * @private + */ +function setupProfileDialog(): void { + profileButton.addEventListener('click', () => { + dialog.open = true; + loadProfilePicture(); + }); + + dialogClose.addEventListener("click", () => { + dialog.open = false; + }); +} + +setupChatCollapse(); +setupChatSwitching(); +setupProfileDialog(); \ No newline at end of file diff --git a/frontend/src/links.ts b/frontend/src/links.ts deleted file mode 100644 index b051b28..0000000 --- a/frontend/src/links.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { /* loadChat, */ /* logout, */ showLogin, showRegister } from "./auth"; - -const login = document.getElementById("login-link")!; -const register = document.getElementById("register-link")!; -// const chat = document.getElementById("chat-link")!; -// const logoutLink = document.getElementById("logout-link")!; - -login.addEventListener("click", showLogin); -register.addEventListener("click", showRegister); -// chat.addEventListener("click", loadChat); -// logoutLink.addEventListener("click", logout); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 0a92896..f089f78 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,8 +1,14 @@ +/** + * @fileoverview Application entry point for FromChat frontend + * @description Main module that initializes all required components and styles + * @author Cursor + * @version 1.0.0 + */ + import './css/style.scss'; import "mdui/mdui.css"; -import "./links"; -import "./material"; +import "./utils/material"; import "./chat"; import "./settings"; import "./leftpanel"; diff --git a/frontend/src/profile.ts b/frontend/src/profile.ts index cbcf271..2ff08f4 100644 --- a/frontend/src/profile.ts +++ b/frontend/src/profile.ts @@ -1,3 +1,10 @@ +/** + * @fileoverview Profile module entry point and initialization + * @description Coordinates profile system initialization and form handling + * @author Cursor + * @version 1.0.0 + */ + import type { Dialog } from "mdui/components/dialog"; import { loadProfileData } from './profile/editor'; import { loadProfilePicture, initializeProfileUpload } from "./profile/upload"; @@ -15,7 +22,13 @@ form.addEventListener("submit", async (e) => { dialog.open = false; }); -// Initialize profile functionality after login +/** + * Initializes profile functionality after user login + * @function initializeProfile + * @example + * // Called after successful authentication + * initializeProfile(); + */ export function initializeProfile(): void { // Initialize profile modules initializeProfileUpload(); diff --git a/frontend/src/profile/README.md b/frontend/src/profile/README.md deleted file mode 100644 index 67aabce..0000000 --- a/frontend/src/profile/README.md +++ /dev/null @@ -1,118 +0,0 @@ -# Profile Module Structure - -This directory contains the modularized profile functionality for the FromChat application. - -## Structure - -``` -profile/ -├── types.ts # Type definitions -├── notification.ts # Notification system -├── image-cropper.ts # Image cropping functionality -├── profile-service.ts # API service layer -├── profile-upload.ts # Upload management -├── profile-editor.ts # Profile editing functionality -└── README.md # This file -``` - -## Modules - -### `types.ts` -Contains all TypeScript interfaces and types used across the profile module: -- `ProfileData` - User profile data structure -- `UploadResponse` - API response for uploads -- `NotificationType` - Notification types -- `CropPosition` - Image cropping position -- `DragStart` - Drag operation start position - -### `notification.ts` -Top-level notification functions for displaying success/error messages: -- `showSuccess(message: string)` - Show success notification -- `showError(message: string)` - Show error notification - -### `image-cropper.ts` -Canvas-based image cropper for profile pictures: -- `ImageCropper` - Main cropper class with touch/mouse support -- Handles circular cropping with drag functionality - -### `profile-service.ts` -API service layer for profile operations: -- `loadProfile()` - Load user profile data -- `uploadProfilePicture(file: Blob)` - Upload profile picture -- `updateProfile(data: Partial)` - Update profile data - -### `profile-upload.ts` -Manages profile picture upload workflow: -- `loadProfilePicture()` - Load and display profile picture -- Global variables and event listeners for upload UI -- Handles file selection, cropping, and upload - -### `profile-editor.ts` -Manages profile text editing (nickname, description): -- `loadProfileData()` - Load profile text data -- `setNicknameValue(value: string)` - Set nickname value -- `setDescriptionValue(value: string)` - Set description value -- `getNicknameValue()` - Get current nickname -- `getDescriptionValue()` - Get current description -- Global event listeners for edit buttons -- Supports Enter to save, Escape to cancel - -## Usage - -### Direct Imports -```typescript -// Import specific functions from each module -import { loadProfile, uploadProfilePicture, updateProfile } from './profile/profile-service'; -import { loadProfilePicture } from './profile/profile-upload'; -import { loadProfileData, setNicknameValue } from './profile/profile-editor'; -import { showSuccess, showError } from './profile/notification'; -import { ImageCropper } from './profile/image-cropper'; -``` - -### Loading Profile Data -```typescript -// Load profile picture -await loadProfilePicture(); - -// Load profile text data -await loadProfileData(); -``` - -### Uploading Profile Picture -The upload process is handled automatically by the global event listeners in `profile-upload.ts` when users interact with the upload UI. - -### Editing Profile Text -The editing process is handled automatically by the global event listeners in `profile-editor.ts` when users interact with the edit buttons. - -### Showing Notifications -```typescript -showSuccess('Operation completed successfully!'); -showError('Something went wrong!'); -``` - -### API Operations -```typescript -// Load profile data -const profileData = await loadProfile(); - -// Update profile -const success = await updateProfile({ nickname: 'New Name' }); - -// Upload profile picture -const result = await uploadProfilePicture(blob); -``` - -## Benefits of This Structure - -1. **Separation of Concerns**: Each module has a single responsibility -2. **Reusability**: Functions can be used independently -3. **Maintainability**: Easier to find and fix issues -4. **Testability**: Each function can be tested in isolation -5. **Type Safety**: Strong TypeScript typing throughout -6. **Top-level Functions**: Simple function calls instead of class instances -7. **Direct Imports**: Import only what you need from specific modules -8. **No Index File**: Direct imports reduce complexity and improve tree shaking - -## Migration from Original Files - -The original `profile.ts` and `profile-upload.ts` files have been refactored to use this modular structure. The functionality remains the same, but it's now better organized and more maintainable. diff --git a/frontend/src/profile/api.ts b/frontend/src/profile/api.ts index 7b5ba4e..32c8220 100644 --- a/frontend/src/profile/api.ts +++ b/frontend/src/profile/api.ts @@ -1,6 +1,24 @@ +/** + * @fileoverview Profile-related API calls + * @description Handles all profile-related HTTP requests to the backend + * @author Cursor + * @version 1.0.0 + */ + import { getAuthHeaders } from '../auth'; import type { ProfileData, UploadResponse } from './types'; +/** + * Loads user profile data from the server + * @async + * @function loadProfile + * @returns {Promise} User profile data or null if failed + * @example + * const profile = await loadProfile(); + * if (profile) { + * console.log('User nickname:', profile.nickname); + * } + */ export async function loadProfile(): Promise { try { const response = await fetch('/api/user/profile', { @@ -18,6 +36,20 @@ export async function loadProfile(): Promise { } } +/** + * Uploads a profile picture to the server + * @async + * @function uploadProfilePicture + * @param {Blob} file - The image file to upload + * @returns {Promise} Upload response with URL or null if failed + * @example + * const fileInput = document.getElementById('file-input'); + * const file = fileInput.files[0]; + * const result = await uploadProfilePicture(file); + * if (result) { + * console.log('Uploaded to:', result.profile_picture_url); + * } + */ export async function uploadProfilePicture(file: Blob): Promise { try { const formData = new FormData(); @@ -39,6 +71,21 @@ export async function uploadProfilePicture(file: Blob): Promise} data - Profile data to update + * @returns {Promise} True if update was successful, false otherwise + * @example + * const success = await updateProfile({ + * nickname: 'New Name', + * description: 'Updated bio' + * }); + * if (success) { + * console.log('Profile updated successfully'); + * } + */ export async function updateProfile(data: Partial): Promise { try { const response = await fetch('/api/user/profile', { diff --git a/frontend/src/profile/editor.ts b/frontend/src/profile/editor.ts index 40422ff..d37aebf 100644 --- a/frontend/src/profile/editor.ts +++ b/frontend/src/profile/editor.ts @@ -1,26 +1,59 @@ +/** + * @fileoverview Profile editing functionality + * @description Handles profile form editing and MDUI text field integration + * @author Cursor + * @version 1.0.0 + */ + import { updateProfile } from './api'; import { loadProfile } from './api'; -import { showSuccess, showError } from '../notification'; +import { showSuccess, showError } from '../utils/notification'; import type { TextField } from 'mdui/components/text-field'; -// DOM elements -let profileForm: HTMLElement; -let nicknameField: TextField; // MDUI TextField -let descriptionField: TextField; // MDUI TextField +let profileForm = document.getElementById('profile-form')!; +let nicknameField = document.getElementById('username-field') as unknown as TextField; +let descriptionField = document.getElementById('description-field') as unknown as TextField; + +/** + * Initialization state flag + * @type {boolean} + */ let isInitialized = false; +/** + * Sets the username field value + * @param {string} value - The username value to set + * @function setUsernameValue + * @example + * setUsernameValue('John Doe'); + */ export function setUsernameValue(value: string): void { if (nicknameField && nicknameField.value !== undefined) { nicknameField.value = value; } } +/** + * Sets the description field value + * @param {string} value - The description value to set + * @function setDescriptionValue + * @example + * setDescriptionValue('Software Developer'); + */ export function setDescriptionValue(value: string): void { if (descriptionField && descriptionField.value !== undefined) { descriptionField.value = value; } } +/** + * Gets the current username field value + * @returns {string} The current username value + * @function getUsernameValue + * @example + * const username = getUsernameValue(); + * console.log('Current username:', username); + */ export function getUsernameValue(): string { if (nicknameField && nicknameField.value !== undefined) { return nicknameField.value; @@ -28,6 +61,14 @@ export function getUsernameValue(): string { return ''; } +/** + * Gets the current description field value + * @returns {string} The current description value + * @function getDescriptionValue + * @example + * const description = getDescriptionValue(); + * console.log('Current description:', description); + */ export function getDescriptionValue(): string { if (descriptionField && descriptionField.value !== undefined) { return descriptionField.value; @@ -35,6 +76,13 @@ export function getDescriptionValue(): string { return ''; } +/** + * Loads profile data from the server and populates the form fields + * @async + * @function loadProfileData + * @example + * await loadProfileData(); + */ export async function loadProfileData(): Promise { const userData = await loadProfile(); if (userData) { @@ -47,39 +95,57 @@ export async function loadProfileData(): Promise { } } -// Setup form submission handler +/** + * Handles profile form submission + * @async + * @function handleFormSubmission + * @param {Event} e - Form submission event + * @private + */ +async function handleFormSubmission(e: Event): Promise { + e.preventDefault(); + + const nickname = getUsernameValue(); + const description = getDescriptionValue(); + + if (nickname || description) { + const success = await updateProfile({ + nickname: nickname || undefined, + description: description || undefined + }); + + if (success) { + showSuccess('Профиль обновлен!'); + } else { + showError('Ошибка при обновлении профиля'); + } + } +} + +/** + * Sets up form submission handler + * @function setupFormHandler + * @private + */ function setupFormHandler(): void { if (isInitialized) return; // Get DOM elements profileForm = document.getElementById('profile-form')!; - nicknameField = document.getElementById('username-field') as any; // MDUI TextField - descriptionField = document.getElementById('description-field') as any; // MDUI TextField + nicknameField = document.getElementById('username-field') as any; + descriptionField = document.getElementById('description-field') as any; - profileForm.addEventListener('submit', async (e) => { - e.preventDefault(); - - const nickname = getUsernameValue(); - const description = getDescriptionValue(); - - if (nickname || description) { - const success = await updateProfile({ - nickname: nickname || undefined, - description: description || undefined - }); - - if (success) { - showSuccess('Профиль обновлен!'); - } else { - showError('Ошибка при обновлении профиля'); - } - } - }); + profileForm.addEventListener('submit', handleFormSubmission); isInitialized = true; } -// Initialize editor functionality +/** + * Initializes profile editor functionality + * @function initializeProfileEditor + * @example + * initializeProfileEditor(); + */ export function initializeProfileEditor(): void { setupFormHandler(); } diff --git a/frontend/src/profile/image-cropper.ts b/frontend/src/profile/image-cropper.ts index 45ac17c..c4468d1 100644 --- a/frontend/src/profile/image-cropper.ts +++ b/frontend/src/profile/image-cropper.ts @@ -1,14 +1,56 @@ +/** + * @fileoverview Canvas-based image cropping component + * @description Provides circular image cropping functionality with drag support + * @author Cursor + * @version 1.0.0 + */ + import type { Size2D } from "../types"; +/** + * Image cropper class for circular profile picture cropping + * @class ImageCropper + */ export class ImageCropper { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; + + /** + * Image element to be cropped + * @type {HTMLImageElement} + * @private + */ private image!: HTMLImageElement; + + /** + * Size of the crop area (diameter) + * @type {number} + * @private + */ private cropSize: number = 200; private isDragging: boolean = false; + + /** + * Starting position of the drag operation + * @type {Size2D} + * @private + */ private dragStart: Size2D = { x: 0, y: 0 }; + + /** + * Current position of the crop area + * @type {Size2D} + * @private + */ private cropPosition: Size2D = { x: 0, y: 0 }; + /** + * Creates a new ImageCropper instance + * @param {HTMLElement} container - Container element to append the canvas to + * @constructor + * @example + * const cropper = new ImageCropper(document.getElementById('cropper-area')); + */ constructor(container: HTMLElement) { this.canvas = document.createElement('canvas'); this.canvas.width = this.cropSize; @@ -19,6 +61,11 @@ export class ImageCropper { this.setupEventListeners(); } + /** + * Sets up mouse and touch event listeners + * @function setupEventListeners + * @private + */ private setupEventListeners(): void { this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this)); this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this)); @@ -28,11 +75,23 @@ export class ImageCropper { this.canvas.addEventListener('touchend', this.onTouchEnd.bind(this)); } + /** + * Handles mouse down events + * @param {MouseEvent} e - Mouse event + * @function onMouseDown + * @private + */ private onMouseDown(e: MouseEvent): void { this.isDragging = true; this.dragStart = { x: e.clientX, y: e.clientY }; } + /** + * Handles mouse move events during dragging + * @param {MouseEvent} e - Mouse event + * @function onMouseMove + * @private + */ private onMouseMove(e: MouseEvent): void { if (!this.isDragging) return; @@ -46,10 +105,21 @@ export class ImageCropper { this.render(); } + /** + * Handles mouse up events + * @function onMouseUp + * @private + */ private onMouseUp(): void { this.isDragging = false; } + /** + * Handles touch start events + * @param {TouchEvent} e - Touch event + * @function onTouchStart + * @private + */ private onTouchStart(e: TouchEvent): void { e.preventDefault(); const touch = e.touches[0]; @@ -57,6 +127,12 @@ export class ImageCropper { this.dragStart = { x: touch.clientX, y: touch.clientY }; } + /** + * Handles touch move events during dragging + * @param {TouchEvent} e - Touch event + * @function onTouchMove + * @private + */ private onTouchMove(e: TouchEvent): void { e.preventDefault(); if (!this.isDragging) return; @@ -72,10 +148,23 @@ export class ImageCropper { this.render(); } + /** + * Handles touch end events + * @function onTouchEnd + * @private + */ private onTouchEnd(): void { this.isDragging = false; } + /** + * Loads an image file for cropping + * @param {File} file - Image file to load + * @returns {Promise} Promise that resolves when image is loaded + * @async + * @example + * await cropper.loadImage(fileInput.files[0]); + */ loadImage(file: File): Promise { return new Promise((resolve) => { this.image = new Image(); @@ -87,6 +176,11 @@ export class ImageCropper { }); } + /** + * Renders the image with circular crop overlay + * @function render + * @private + */ private render(): void { if (!this.image) return; @@ -119,10 +213,23 @@ export class ImageCropper { this.ctx.restore(); } + /** + * Gets the cropped image as a data URL + * @returns {string} Data URL of the cropped image + * @example + * const croppedImage = cropper.getCroppedImage(); + * // Use croppedImage as src for an img element + */ getCroppedImage(): string { return this.canvas.toDataURL('image/jpeg', 0.8); } + /** + * Destroys the cropper and removes the canvas from DOM + * @function destroy + * @example + * cropper.destroy(); + */ destroy(): void { if (this.canvas.parentNode) { this.canvas.parentNode.removeChild(this.canvas); diff --git a/frontend/src/profile/types.ts b/frontend/src/profile/types.ts index 7a9aa34..ec134c6 100644 --- a/frontend/src/profile/types.ts +++ b/frontend/src/profile/types.ts @@ -1,9 +1,28 @@ +/** + * @fileoverview Profile-specific type definitions + * @description Contains type definitions for profile-related functionality + * @author Cursor + * @version 1.0.0 + */ + +/** + * User profile data structure + * @interface ProfileData + * @property {string} [profile_picture] - URL to user's profile picture + * @property {string} [nickname] - User's display name + * @property {string} [description] - User's bio or description + */ export interface ProfileData { profile_picture?: string; nickname?: string; description?: string; } +/** + * Profile picture upload response structure + * @interface UploadResponse + * @property {string} profile_picture_url - URL to the uploaded profile picture + */ export interface UploadResponse { profile_picture_url: string; } \ No newline at end of file diff --git a/frontend/src/profile/upload.ts b/frontend/src/profile/upload.ts index 889e5c4..0745eb7 100644 --- a/frontend/src/profile/upload.ts +++ b/frontend/src/profile/upload.ts @@ -1,34 +1,111 @@ +/** + * @fileoverview Profile picture upload functionality + * @description Handles file selection, image cropping, and profile picture upload + * @author Cursor + * @version 1.0.0 + */ + import type { Dialog } from "mdui/components/dialog"; import { ImageCropper } from './image-cropper'; import { uploadProfilePicture } from './api'; import { loadProfile } from './api'; -import { showSuccess, showError } from '../notification'; +import { showSuccess, showError } from '../utils/notification'; -// Global variables +/** + * Global image cropper instance + * @type {ImageCropper | null} + */ let cropper: ImageCropper | null = null; + +/** + * Initialization state flag + * @type {boolean} + */ let isInitialized = false; -// DOM elements -let cropperDialog: Dialog; -let fileInput: HTMLInputElement; -let uploadBtn: HTMLElement; -let cropSaveBtn: HTMLElement; -let cropCancelBtn: HTMLElement; -let cropperCloseBtn: HTMLElement; -let cropperArea: HTMLElement; +let cropperDialog = document.getElementById('cropper-dialog') as Dialog; +let fileInput = document.getElementById('pfp-file-input') as HTMLInputElement; +let uploadBtn = document.getElementById('upload-pfp-btn')!; +let cropSaveBtn = document.getElementById('crop-save')!; +let cropCancelBtn = document.getElementById('crop-cancel')!; +let cropperCloseBtn = document.getElementById('cropper-close')!; +let cropperArea = document.getElementById('cropper-area')!; -// Setup event listeners +/** + * Opens the image cropper with the selected file + * @async + * @function openCropper + * @param {File} file - The image file to crop + * @private + */ +async function openCropper(file: File): Promise { + // Clear previous cropper + cropperArea.innerHTML = ''; + + // Create new cropper + cropper = new ImageCropper(cropperArea); + + // Load image + await cropper.loadImage(file); + + // Open dialog + cropperDialog.open = true; +} + +/** + * Closes the image cropper and cleans up resources + * @function closeCropper + * @private + */ +function closeCropper(): void { + cropperDialog.open = false; + cropperArea.innerHTML = ''; + if (cropper) { + cropper.destroy(); + cropper = null; + } + fileInput.value = ''; +} + +/** + * Saves the cropped image and uploads it to the server + * @async + * @function saveCroppedImage + * @private + */ +async function saveCroppedImage(): Promise { + if (!cropper) return; + + const croppedImageData = cropper.getCroppedImage(); + + // Convert data URL to blob + const response = await fetch(croppedImageData); + const blob = await response.blob(); + + const result = await uploadProfilePicture(blob); + + if (result) { + // Update profile picture display + const profilePicture = document.getElementById('profile-picture') as HTMLImageElement; + profilePicture.src = result.profile_picture_url + '?t=' + Date.now(); // Cache bust + + // Close cropper + closeCropper(); + + // Show success message + showSuccess('Фото профиля обновлено!'); + } else { + showError('Ошибка при загрузке фото'); + } +} + +/** + * Sets up event listeners for upload functionality + * @function setupEventListeners + * @private + */ function setupEventListeners(): void { if (isInitialized) return; - - // Get DOM elements - cropperDialog = document.getElementById('cropper-dialog') as Dialog; - fileInput = document.getElementById('pfp-file-input') as HTMLInputElement; - uploadBtn = document.getElementById('upload-pfp-btn')!; - cropSaveBtn = document.getElementById('crop-save')!; - cropCancelBtn = document.getElementById('crop-cancel')!; - cropperCloseBtn = document.getElementById('cropper-close')!; - cropperArea = document.getElementById('cropper-area')!; uploadBtn.addEventListener('click', () => { fileInput.click(); @@ -56,65 +133,31 @@ function setupEventListeners(): void { isInitialized = true; } -async function openCropper(file: File): Promise { - // Clear previous cropper - cropperArea.innerHTML = ''; - - // Create new cropper - cropper = new ImageCropper(cropperArea); - - // Load image - await cropper.loadImage(file); - - // Open dialog - cropperDialog.open = true; -} - -function closeCropper(): void { - cropperDialog.open = false; - cropperArea.innerHTML = ''; - if (cropper) { - cropper.destroy(); - cropper = null; - } - fileInput.value = ''; -} - -async function saveCroppedImage(): Promise { - if (!cropper) return; - - const croppedImageData = cropper.getCroppedImage(); - - // Convert data URL to blob - const response = await fetch(croppedImageData); - const blob = await response.blob(); - - const result = await uploadProfilePicture(blob); - - if (result) { - // Update profile picture display - const profilePicture = document.getElementById('profile-picture') as HTMLImageElement; - profilePicture.src = result.profile_picture_url + '?t=' + Date.now(); // Cache bust - - // Close cropper - closeCropper(); - - // Show success message - showSuccess('Фото профиля обновлено!'); - } else { - showError('Ошибка при загрузке фото'); - } -} - +/** + * Loads and displays the user's profile picture + * @async + * @function loadProfilePicture + * @example + * await loadProfilePicture(); + */ export async function loadProfilePicture(): Promise { const userData = await loadProfile(); if (userData?.profile_picture) { + const url = `${userData.profile_picture}?t=${Date.now()}`; + const profilePicture = document.getElementById('profile-picture') as HTMLImageElement; - profilePicture.src = userData.profile_picture + '?t=' + Date.now(); // Cache bust + const profilePicture2 = document.getElementById("preview1") as HTMLImageElement; + profilePicture.src = url; + profilePicture2.src = url; } } -// Initialize upload functionality +/** + * Initializes profile upload functionality + * @function initializeProfileUpload + * @example + * initializeProfileUpload(); + */ export function initializeProfileUpload(): void { setupEventListeners(); } diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index e792714..3c66910 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -1,3 +1,10 @@ +/** + * @fileoverview Settings dialog management and panel navigation + * @description Handles settings dialog functionality and dynamic panel switching + * @author Cursor + * @version 1.0.0 + */ + import type { Dialog } from "mdui/components/dialog"; const dialog = document.getElementById('settings-dialog') as Dialog; @@ -8,49 +15,65 @@ const closeButton = document.getElementById('settings-close')!; const settingsList = document.querySelector('#settings-menu mdui-list')!; const settingsPanels = document.querySelectorAll('.settings-panel'); -// Initialize settings -function initializeSettings() { - // Add click listeners to all list items +/** + * Mapping between list item text and their corresponding panel IDs + * @type {Object.} + */ +const panelMapping = { + 'Уведомления': 'notifications-settings', + 'Внешний вид': 'appearance-settings', + 'Безопасность': 'security-settings', + 'Язык': 'language-settings', + 'Хранилище': 'storage-settings', + 'Помощь': 'help-settings', + 'О приложении': 'about-settings' +}; + +/** + * Handles click events on settings list items + * @param {Element} item - The clicked list item element + * @function handleListItemClick + * @private + */ +function handleListItemClick(item: Element): void { + // Remove active class from all items and panels const listItems = settingsList.querySelectorAll('mdui-list-item'); + listItems.forEach(li => li.removeAttribute('active')); + settingsPanels.forEach(panel => panel.classList.remove('active')); - // Create a mapping between list items and their corresponding panels - const panelMapping = { - 'Уведомления': 'notifications-settings', - 'Внешний вид': 'appearance-settings', - 'Безопасность': 'security-settings', - 'Язык': 'language-settings', - 'Хранилище': 'storage-settings', - 'Помощь': 'help-settings', - 'О приложении': 'about-settings' - }; + // Add active class to clicked item + item.setAttribute('active', ''); + // Show corresponding panel using the mapping + const itemText = item.textContent?.trim(); + const panelId = panelMapping[itemText as keyof typeof panelMapping]; + + if (panelId) { + const targetPanel = document.getElementById(panelId); + if (targetPanel) { + targetPanel.classList.add('active'); + } + } +} + +/** + * Sets up click listeners for all settings list items + * @function setupSettingsNavigation + * @private + */ +function setupSettingsNavigation(): void { + const listItems = settingsList.querySelectorAll('mdui-list-item'); listItems.forEach((item) => { - item.addEventListener('click', () => { - // Remove active class from all items and panels - listItems.forEach(li => li.removeAttribute('active')); - settingsPanels.forEach(panel => panel.classList.remove('active')); - - // Add active class to clicked item - item.setAttribute('active', ''); - - // Show corresponding panel using the mapping - const itemText = item.textContent?.trim(); - const panelId = panelMapping[itemText as keyof typeof panelMapping]; - - if (panelId) { - const targetPanel = document.getElementById(panelId); - if (targetPanel) { - targetPanel.classList.add('active'); - } - } - }); + item.addEventListener('click', () => handleListItemClick(item)); }); } -// Dialog event listeners -openButton.addEventListener('click', () => { - dialog.open = true; - // Reset to first panel when opening +/** + * Resets settings dialog to show the first panel + * @function resetToFirstPanel + * @private + */ +function resetToFirstPanel(): void { const firstItem = settingsList.querySelector('mdui-list-item'); const firstPanel = document.querySelector('.settings-panel'); if (firstItem && firstPanel) { @@ -59,15 +82,23 @@ openButton.addEventListener('click', () => { firstItem.setAttribute('active', ''); firstPanel.classList.add('active'); } -}); - -closeButton.addEventListener('click', () => { - dialog.open = false; -}); - -// Initialize when DOM is loaded -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeSettings); -} else { - initializeSettings(); } + +/** + * Sets up dialog event listeners + * @function setupDialogListeners + * @private + */ +function setupDialogListeners(): void { + openButton.addEventListener('click', () => { + dialog.open = true; + resetToFirstPanel(); + }); + + closeButton.addEventListener('click', () => { + dialog.open = false; + }); +} + +setupSettingsNavigation(); +setupDialogListeners(); \ No newline at end of file diff --git a/frontend/src/types.ts b/frontend/src/types.ts index acb61c5..abbecc2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,16 +1,48 @@ +/** + * @fileoverview Global TypeScript type definitions + * @description Contains all type definitions used throughout the application + * @author Cursor + * @version 1.0.0 + */ + +/** + * HTTP headers object type + * @typedef {Object.} Headers + */ export type Headers = {[x: string]: string} +/** + * API error response structure + * @interface ErrorResponse + * @property {string} message - Error message from the server + */ export interface ErrorResponse { message: string; } +/** + * 2D coordinate structure + * @interface Size2D + * @property {number} x - X coordinate + * @property {number} y - Y coordinate + */ export interface Size2D { x: number; y: number; } - // App types + +/** + * Chat message structure + * @interface Message + * @property {number} id - Unique message identifier + * @property {string} username - Username of the message sender + * @property {string} content - Message content + * @property {boolean} is_read - Whether the message has been read + * @property {string} timestamp - ISO timestamp of the message + * @property {string} [profile_picture] - URL to sender's profile picture + */ export interface Message { id: number; username: string; @@ -20,10 +52,24 @@ export interface Message { profile_picture?: string; } +/** + * Collection of messages + * @interface Messages + * @property {Message[]} messages - Array of message objects + */ export interface Messages { messages: Message[]; } +/** + * User information structure + * @interface User + * @property {number} id - Unique user identifier + * @property {string} created_at - ISO timestamp of account creation + * @property {string} last_seen - ISO timestamp of last activity + * @property {boolean} online - Whether the user is currently online + * @property {string} username - Username + */ export interface User { id: number; created_at: string; @@ -32,17 +78,30 @@ export interface User { username: string; } - // ---------- // API models // ---------- // Requests + +/** + * Login request structure + * @interface LoginRequest + * @property {string} username - Username for authentication + * @property {string} password - Password for authentication + */ export interface LoginRequest { username: string; password: string; } +/** + * Registration request structure + * @interface RegisterRequest + * @property {string} username - Desired username + * @property {string} password - Desired password + * @property {string} confirm_password - Password confirmation + */ export interface RegisterRequest { username: string; password: string; @@ -50,6 +109,13 @@ export interface RegisterRequest { } // Responses + +/** + * Login response structure + * @interface LoginResponse + * @property {User} user - User information + * @property {string} token - JWT authentication token + */ export interface LoginResponse { user: User; token: string; @@ -59,6 +125,14 @@ export interface LoginResponse { // WebSocket types // --------------- +/** + * WebSocket message structure + * @interface WebSocketMessage + * @property {string} type - Message type identifier + * @property {WebSocketCredentials} [credentials] - Authentication credentials + * @property {any} [data] - Message payload data + * @property {WebSocketError} [error] - Error information if applicable + */ export interface WebSocketMessage { type: string; credentials?: WebSocketCredentials; @@ -66,11 +140,23 @@ export interface WebSocketMessage { error?: WebSocketError; } +/** + * WebSocket error structure + * @interface WebSocketError + * @property {number} code - Error code + * @property {string} detail - Error detail message + */ export interface WebSocketError { code: number; detail: string; } +/** + * WebSocket authentication credentials + * @interface WebSocketCredentials + * @property {string} scheme - Authentication scheme (e.g., "Bearer") + * @property {string} credentials - Authentication token or credentials + */ export interface WebSocketCredentials { scheme: string; credentials: string; diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts deleted file mode 100644 index 84499f3..0000000 --- a/frontend/src/utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -export function formatTime(dateString: string) { - const date = new Date(dateString); - let hours = date.getHours(); - let minutes = date.getMinutes(); - const hoursString = hours < 10 ? '0' + hours : hours; - const minutesString = minutes < 10 ? '0' + minutes : minutes; - return hoursString + ':' + minutesString; -} - -export function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} \ No newline at end of file diff --git a/frontend/src/material.ts b/frontend/src/utils/material.ts similarity index 76% rename from frontend/src/material.ts rename to frontend/src/utils/material.ts index a8dd8e1..265d42b 100644 --- a/frontend/src/material.ts +++ b/frontend/src/utils/material.ts @@ -1,3 +1,10 @@ +/** + * @fileoverview MDUI component imports and configuration + * @description Imports all required MDUI components and sets up the theme + * @author Cursor + * @version 1.0.0 + */ + import 'mdui/components/tabs'; import 'mdui/components/tab'; import 'mdui/components/tab-panel'; diff --git a/frontend/src/notification.ts b/frontend/src/utils/notification.ts similarity index 55% rename from frontend/src/notification.ts rename to frontend/src/utils/notification.ts index 831e0a4..b655dc1 100644 --- a/frontend/src/notification.ts +++ b/frontend/src/utils/notification.ts @@ -1,5 +1,23 @@ +/** + * @fileoverview User notification system + * @description Provides toast-style notifications for user feedback + * @author Cursor + * @version 1.0.0 + */ + +/** + * Notification type enumeration + * @typedef {'success' | 'error'} NotificationType + */ export type NotificationType = 'success' | 'error'; +/** + * Shows a notification with the specified message and type + * @param {string} message - The message to display + * @param {NotificationType} type - The type of notification (success or error) + * @function showNotification + * @private + */ function showNotification(message: string, type: NotificationType): void { const notification = document.createElement('div'); notification.textContent = message; @@ -28,10 +46,24 @@ function showNotification(message: string, type: NotificationType): void { }, 3000); } +/** + * Shows a success notification + * @param {string} message - The success message to display + * @function showSuccess + * @example + * showSuccess('Profile updated successfully!'); + */ export function showSuccess(message: string): void { showNotification(message, 'success'); } +/** + * Shows an error notification + * @param {string} message - The error message to display + * @function showError + * @example + * showError('Failed to update profile'); + */ export function showError(message: string): void { showNotification(message, 'error'); } diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts new file mode 100644 index 0000000..2220c33 --- /dev/null +++ b/frontend/src/utils/utils.ts @@ -0,0 +1,33 @@ +/** + * @fileoverview Utility functions used throughout the application + * @description Contains helper functions for common operations + * @author Cursor + * @version 1.0.0 + */ + +/** + * Formats a timestamp string to HH:MM format + * @param {string} dateString - ISO timestamp string to format + * @returns {string} Formatted time string in HH:MM format + * @example + * formatTime('2024-01-15T14:30:00Z'); // Returns "14:30" + */ +export function formatTime(dateString: string): string { + const date = new Date(dateString); + let hours = date.getHours(); + let minutes = date.getMinutes(); + const hoursString = hours < 10 ? '0' + hours : hours; + const minutesString = minutes < 10 ? '0' + minutes : minutes; + return hoursString + ':' + minutesString; +} + +/** + * Creates a promise that resolves after a specified delay + * @param {number} ms - Delay time in milliseconds + * @returns {Promise} Promise that resolves after the delay + * @example + * await delay(1000); // Wait for 1 second + */ +export function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} \ No newline at end of file diff --git a/frontend/src/websocket.ts b/frontend/src/websocket.ts index 6cafd7e..57460e9 100644 --- a/frontend/src/websocket.ts +++ b/frontend/src/websocket.ts @@ -1,13 +1,30 @@ +/** + * @fileoverview WebSocket connection management for real-time chat + * @description Handles WebSocket connections, message processing, and auto-reconnection + * @author Cursor + * @version 1.0.0 + */ + import { currentUser } from "./auth"; import { addMessage } from "./chat"; import { API_FULL_BASE_URL } from "./config"; import type { WebSocketMessage, Message } from "./types"; -import { delay } from "./utils"; +import { delay } from "./utils/utils"; -function create() { +/** + * Creates a new WebSocket connection to the chat server + * @function create + * @returns {WebSocket} New WebSocket instance + * @private + */ +function create(): WebSocket { return new WebSocket(`ws://${API_FULL_BASE_URL}/chat/ws`); } +/** + * Global WebSocket instance + * @type {WebSocket} + */ export let websocket = create(); // -------------- From 1eb4f8392a6f15d64c84c80f61c6e6458c6de815 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 23 Aug 2025 15:21:26 +0300 Subject: [PATCH 6/7] Implement message replying, deleting and editing --- .cursor/rules/ui.mdc | 8 + backend/dependencies.py | 2 +- backend/models.py | 36 ++- backend/routes/messaging.py | 172 +++++++++-- backend/routes/profile.py | 48 ++- frontend/src/chat.ts | 107 ++++++- frontend/src/css/_chat.scss | 250 ++++++++++++++- frontend/src/main.ts | 4 +- frontend/src/message-context-menu.ts | 437 +++++++++++++++++++++++++++ frontend/src/profile/api.ts | 30 ++ frontend/src/types.ts | 27 ++ frontend/src/user-profile-dialog.ts | 282 +++++++++++++++++ frontend/src/websocket.ts | 15 +- 13 files changed, 1372 insertions(+), 46 deletions(-) create mode 100644 .cursor/rules/ui.mdc create mode 100644 frontend/src/message-context-menu.ts create mode 100644 frontend/src/user-profile-dialog.ts diff --git a/.cursor/rules/ui.mdc b/.cursor/rules/ui.mdc new file mode 100644 index 0000000..6791c5c --- /dev/null +++ b/.cursor/rules/ui.mdc @@ -0,0 +1,8 @@ +--- +alwaysApply: true +--- + +When you work with UI: + +1. Use MDUI components as HTML elements +2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML. \ No newline at end of file diff --git a/backend/dependencies.py b/backend/dependencies.py index 7dd7199..f1b47ab 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -19,7 +19,7 @@ def get_db(): def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db) -): +) -> User: token = credentials.credentials payload = verify_token(token) if not payload: diff --git a/backend/models.py b/backend/models.py index 00e7f90..50271d4 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,5 +1,5 @@ from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, text from sqlalchemy.orm import relationship from datetime import datetime from db import engine @@ -16,6 +16,7 @@ class User(Base): username = Column(String(50), unique=True, nullable=False, index=True) password_hash = Column(String(200), nullable=False) profile_picture = Column(String(255), nullable=True) + bio = Column(Text, nullable=True) online = Column(Boolean, default=False) last_seen = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now) @@ -30,8 +31,11 @@ class Message(Base): timestamp = Column(DateTime, default=datetime.now) user_id = Column(Integer, ForeignKey("user.id"), nullable=False) is_read = Column(Boolean, default=False) + reply_to_id = Column(Integer, ForeignKey("message.id"), nullable=True) + is_edited = Column(Boolean, default=False) author = relationship("User", back_populates="messages") + reply_to = relationship("Message", remote_side=[id]) # Pydantic модели @@ -50,6 +54,36 @@ class SendMessageRequest(BaseModel): content: str +class EditMessageRequest(BaseModel): + content: str + + +class ReplyMessageRequest(BaseModel): + content: str + reply_to_id: int + + +class DeleteMessageRequest(BaseModel): + message_id: int + + +class UpdateBioRequest(BaseModel): + bio: str + + +class UserProfileResponse(BaseModel): + id: int + username: str + profile_picture: str | None + bio: str | None + online: bool + last_seen: datetime + created_at: datetime + + class Config: + from_attributes = True + + class MessageResponse(BaseModel): id: int content: str diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 9e2bafb..220f273 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco from fastapi.security import HTTPAuthorizationCredentials from sqlalchemy.orm import Session from dependencies import get_current_user, get_db -from models import Message, SendMessageRequest, User +from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -15,23 +15,19 @@ def convert_message(msg: Message) -> dict: "content": msg.content, "timestamp": msg.timestamp.isoformat(), "is_read": msg.is_read, + "is_edited": msg.is_edited, "username": msg.author.username, - "profile_picture": msg.author.profile_picture + "profile_picture": msg.author.profile_picture, + "reply_to": convert_message(msg.reply_to) if msg.reply_to else None } -async def get_messages_inner(db: Session): - messages = db.query(Message).order_by(Message.timestamp.asc()).all() - messages_data = [] - for msg in messages: - messages_data.append(convert_message(msg)) - - return { - "status": "success", - "messages": messages_data - } - -async def send_message_inner(request: SendMessageRequest, current_user: User, db: Session): +@router.post("/send_message") +async def send_message( + request: SendMessageRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): if not request.content.strip(): raise HTTPException( status_code=400, @@ -50,18 +46,94 @@ async def send_message_inner(request: SendMessageRequest, current_user: User, db return {"status": "success", "message": convert_message(new_message)} -@router.post("/send_message") -async def send_message( - request: SendMessageRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - return await send_message_inner(request, current_user, db) - @router.get("/get_messages") async def get_messages(db: Session = Depends(get_db)): - return await get_messages_inner(db) + messages = db.query(Message).order_by(Message.timestamp.asc()).all() + + messages_data = [] + for msg in messages: + messages_data.append(convert_message(msg)) + + return { + "status": "success", + "messages": messages_data + } + + +@router.put("/edit_message/{message_id}") +async def edit_message( + message_id: int, + request: EditMessageRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + message = db.query(Message).filter(Message.id == message_id).first() + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + if message.user_id != current_user.id: + raise HTTPException(status_code=403, detail="You can only edit your own messages") + + if not request.content.strip(): + raise HTTPException(status_code=400, detail="Message content cannot be empty") + + message.content = request.content.strip() + message.is_edited = True + + db.commit() + db.refresh(message) + + return {"status": "success", "message": convert_message(message)} + + +@router.delete("/delete_message/{message_id}") +async def delete_message( + message_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + message = db.query(Message).filter(Message.id == message_id).first() + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + if message.user_id != current_user.id: + raise HTTPException(status_code=403, detail="You can only delete your own messages") + + db.delete(message) + db.commit() + + return {"status": "success", "message_id": message_id} + + +@router.post("/reply_message") +async def reply_message( + request: ReplyMessageRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # Check if the message being replied to exists + original_message = db.query(Message).filter(Message.id == request.reply_to_id).first() + if not original_message: + raise HTTPException(status_code=404, detail="Original message not found") + + if not request.content.strip(): + raise HTTPException(status_code=400, detail="No content provided") + + new_message = Message( + content=request.content.strip(), + user_id=current_user.id, + timestamp=datetime.now(), + reply_to_id=request.reply_to_id + ) + + db.add(new_message) + db.commit() + db.refresh(new_message) + + return {"status": "success", "message": convert_message(new_message)} class MessaggingSocketManager: @@ -96,7 +168,7 @@ class MessaggingSocketManager: if not current_user: raise HTTPException(401) - await websocket.send_json({"type": type, "data": await get_messages_inner(current_user, db)}) + await websocket.send_json({"type": type, "data": await get_messages(current_user, db)}) except HTTPException as e: await self.send_error(websocket, type, e) elif type == "sendMessage": @@ -107,7 +179,57 @@ class MessaggingSocketManager: request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) - response = await send_message_inner(request, current_user, db) + response = await send_message(request, current_user, db) + await self.broadcast({ + "type": "newMessage", + "data": response["message"] + }) + + await websocket.send_json({"type": type, "data": response}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "editMessage": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + message_id = data["data"]["message_id"] + request: EditMessageRequest = EditMessageRequest.model_validate(data["data"]) + + response = await edit_message(message_id, request, current_user, db) + await self.broadcast({ + "type": "messageEdited", + "data": response["message"] + }) + + await websocket.send_json({"type": type, "data": response}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "deleteMessage": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + message_id = data["data"]["message_id"] + response = await delete_message(message_id, current_user, db) + await self.broadcast({ + "type": "messageDeleted", + "data": {"message_id": message_id} + }) + + await websocket.send_json({"type": type, "data": response}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "replyMessage": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + request: ReplyMessageRequest = ReplyMessageRequest.model_validate(data["data"]) + response = await reply_message(request, current_user, db) await self.broadcast({ "type": "newMessage", "data": response["message"] diff --git a/backend/routes/profile.py b/backend/routes/profile.py index a82dfca..c1fa26f 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -7,7 +7,7 @@ import uuid import io from dependencies import get_db, get_current_user -from models import User +from models import User, UpdateBioRequest, UserProfileResponse router = APIRouter() @@ -92,7 +92,53 @@ async def get_user_profile( "id": current_user.id, "username": current_user.username, "profile_picture": current_user.profile_picture, + "bio": current_user.bio, "online": current_user.online, "last_seen": current_user.last_seen, "created_at": current_user.created_at } + + +@router.put("/user/bio") +async def update_user_bio( + request: UpdateBioRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Update current user's bio + """ + if len(request.bio) > 500: # Limit bio to 500 characters + raise HTTPException(status_code=400, detail="Bio must be 500 characters or less") + + current_user.bio = request.bio.strip() + db.commit() + + return { + "message": "Bio updated successfully", + "bio": current_user.bio + } + + +@router.get("/user/{username}") +async def get_user_by_username( + username: str, + db: Session = Depends(get_db) +): + """ + Get user profile by username + """ + user = db.query(User).filter(User.username == username).first() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return UserProfileResponse( + id=user.id, + username=user.username, + profile_picture=user.profile_picture, + bio=user.bio, + online=user.online, + last_seen=user.last_seen, + created_at=user.created_at + ) diff --git a/frontend/src/chat.ts b/frontend/src/chat.ts index 85502e2..fe6d1ce 100644 --- a/frontend/src/chat.ts +++ b/frontend/src/chat.ts @@ -10,6 +10,8 @@ import { API_BASE_URL } from "./config"; import { websocket } from "./websocket"; import type { Message, Messages, WebSocketMessage } from "./types"; import { formatTime } from "./utils/utils"; +import { messageContextMenu } from "./message-context-menu"; +import { userProfileDialog } from "./user-profile-dialog"; /** * Adds a new message to the chat interface @@ -45,6 +47,12 @@ export function addMessage(message: Message, isAuthor: boolean): void { profileImg.src = './src/images/default-avatar.png'; }; + // Add click handler to profile picture + profileImg.style.cursor = 'pointer'; + profileImg.addEventListener('click', () => { + userProfileDialog.show(message.username); + }); + profilePicDiv.appendChild(profileImg); messageDiv.appendChild(profilePicDiv); } @@ -53,16 +61,42 @@ export function addMessage(message: Message, isAuthor: boolean): void { const usernameDiv = document.createElement('div'); usernameDiv.classList.add('message-username'); usernameDiv.textContent = message.username; + + // Add click handler to username + usernameDiv.style.cursor = 'pointer'; + usernameDiv.addEventListener('click', () => { + userProfileDialog.show(message.username); + }); + messageInner.appendChild(usernameDiv); } + // Add reply preview if this is a reply + if (message.reply_to) { + const replyDiv = document.createElement('div'); + replyDiv.classList.add('message-reply'); + replyDiv.innerHTML = ` +
+ ${message.reply_to.username} + ${message.reply_to.content} +
+ `; + messageInner.appendChild(replyDiv); + } + const contentDiv = document.createElement('div'); + contentDiv.classList.add('message-content'); contentDiv.textContent = message.content; messageInner.appendChild(contentDiv); const timeDiv = document.createElement('div'); timeDiv.classList.add('message-time'); - timeDiv.textContent = formatTime(message.timestamp); + + let timeText = formatTime(message.timestamp); + if (message.is_edited) { + timeText += ' (edited)'; + } + timeDiv.textContent = timeText; if (isAuthor && message.is_read) { const checkIcon = document.createElement('span'); @@ -74,6 +108,12 @@ export function addMessage(message: Message, isAuthor: boolean): void { messageDiv.appendChild(messageInner); messagesContainer.appendChild(messageDiv); + // Add right-click context menu + messageDiv.addEventListener('contextmenu', (e) => { + e.preventDefault(); + messageContextMenu.show(message, e.clientX, e.clientY); + }); + // Прокрутка к новому сообщению messagesContainer.scrollTop = messagesContainer.scrollHeight; } @@ -150,4 +190,67 @@ export function sendMessage(): void { document.getElementById('message-form')!.addEventListener('submit', (e) => { e.preventDefault(); sendMessage(); -}); \ No newline at end of file +}); + +/** + * Updates an existing message in the chat interface + * @param {Message} message - Updated message object + * @function updateMessage + */ +export function updateMessage(message: Message): void { + const messageElement = document.querySelector(`[data-id="${message.id}"]`) as HTMLElement; + if (!messageElement) return; + + const contentDiv = messageElement.querySelector('.message-content') as HTMLElement; + const timeDiv = messageElement.querySelector('.message-time') as HTMLElement; + + if (contentDiv) { + contentDiv.textContent = message.content; + } + + if (timeDiv) { + let timeText = formatTime(message.timestamp); + if (message.is_edited) { + timeText += ' (edited)'; + } + timeDiv.textContent = timeText; + } +} + +/** + * Removes a message from the chat interface + * @param {number} messageId - ID of the message to remove + * @function removeMessage + */ +export function removeMessage(messageId: number): void { + const messageElement = document.querySelector(`[data-id="${messageId}"]`) as HTMLElement; + if (messageElement) { + messageElement.remove(); + } +} + +/** + * Handles WebSocket message updates + * @param {WebSocketMessage} response - WebSocket response + * @function handleWebSocketMessage + */ +export function handleWebSocketMessage(response: WebSocketMessage): void { + switch (response.type) { + case 'messageEdited': + if (response.data) { + updateMessage(response.data); + } + break; + case 'messageDeleted': + if (response.data && response.data.message_id) { + removeMessage(response.data.message_id); + } + break; + case 'newMessage': + if (response.data) { + const isAuthor = response.data.username === currentUser?.username; + addMessage(response.data, isAuthor); + } + break; + } +} \ No newline at end of file diff --git a/frontend/src/css/_chat.scss b/frontend/src/css/_chat.scss index 0f143f3..e2f847c 100644 --- a/frontend/src/css/_chat.scss +++ b/frontend/src/css/_chat.scss @@ -7,7 +7,6 @@ background-color: $color-dark-surface-container; color: white; padding: 16px 16px; - position: static; justify-content: end; width: fit-content; z-index: 1000; @@ -70,7 +69,7 @@ background: $color-dark-surface-container; display: flex; align-items: center; - box-shadow: black 0px 0px 20px; + box-shadow: black 0 0 20px; .chat-header-avatar { width: 45px; @@ -89,8 +88,7 @@ h4 { font-size: 1.1rem; - margin: 0; - margin-bottom: 0.2rem; + margin: 0 0 0.2rem; } p { @@ -166,6 +164,12 @@ height: 100%; border-radius: 50%; object-fit: cover; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } } } @@ -175,6 +179,40 @@ position: relative; word-wrap: break-word; + .message-content { + word-wrap: break-word; + margin-bottom: 0.3rem; + } + + .message-reply { + background-color: rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 0.5rem; + margin-bottom: 0.5rem; + border-left: 3px solid $color-dark-primary; + + .reply-content { + display: flex; + flex-direction: column; + gap: 0.2rem; + + .reply-username { + font-weight: 600; + font-size: 0.8rem; + color: $color-dark-primary; + } + + .reply-text { + font-size: 0.85rem; + color: $color-dark-on-surface-variant; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; + } + } + } + .message-time { font-size: 0.7rem; color: $color-dark-on-surface-variant; @@ -209,6 +247,12 @@ font-weight: 600; margin-bottom: 0.3rem; font-size: 0.9rem; + transition: color 0.2s ease; + + &:hover { + color: $color-dark-primary; + text-decoration: underline; + } } } @@ -273,4 +317,202 @@ } } } +} + +// Message Context Menu +.message-context-menu { + position: fixed; + background-color: $color-dark-surface-container; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + padding: 0.5rem 0; + z-index: 1000; + display: none; + min-width: 150px; + max-width: 200px; + white-space: nowrap; + + .context-menu-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + cursor: pointer; + color: $color-dark-on-surface; + transition: background-color 0.2s ease; + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + .material-symbols { + font-size: 1.1rem; + flex-shrink: 0; + } + } +} + +// Dialog content styles +.dialog-content { + padding: 1.5rem; + + h3 { + margin: 0 0 1rem 0; + color: $color-dark-on-surface; + font-size: 1.2rem; + } + + .dialog-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + margin-top: 1.5rem; + } +} + +// Reply preview styles +.reply-preview { + background-color: $color-dark-surface; + border-radius: 8px; + padding: 0.75rem; + margin-bottom: 1rem; + border-left: 3px solid $color-dark-primary; + + .reply-preview-content { + color: $color-dark-on-surface-variant; + font-size: 0.9rem; + line-height: 1.4; + + strong { + color: $color-dark-primary; + } + } +} + +// User profile dialog content styles +.profile-dialog-content { + padding: 1.5rem; + display: flex; + gap: 1.5rem; + + .profile-picture-section { + flex-shrink: 0; + + .profile-picture { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + border: 2px solid $color-dark-outline; + } + } + + .profile-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; + + .username-section { + display: flex; + align-items: center; + gap: 0.75rem; + + .username { + margin: 0; + color: $color-dark-on-surface; + font-size: 1.1rem; + font-weight: 600; + } + + .online-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-weight: 500; + + &.online { + color: $success; + background-color: rgba(76, 175, 80, 0.1); + + .online-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: $success; + } + } + + &.offline { + color: $color-dark-on-surface-variant; + background-color: rgba(255, 255, 255, 0.05); + + .offline-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: $color-dark-on-surface-variant; + } + } + } + } + + .bio-section { + label { + display: block; + color: $color-dark-on-surface-variant; + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 0.5rem; + } + + .bio-display { + color: $color-dark-on-surface; + font-size: 0.9rem; + line-height: 1.4; + padding: 0.75rem; + background-color: $color-dark-surface; + border-radius: 8px; + border: 1px solid $color-dark-outline; + min-height: 60px; + } + + .bio-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + } + } + + .profile-stats { + display: flex; + flex-direction: column; + gap: 0.5rem; + + .stat { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + + .stat-label { + color: $color-dark-on-surface-variant; + font-size: 0.85rem; + } + + .stat-value { + color: $color-dark-on-surface; + font-size: 0.85rem; + font-weight: 500; + } + } + } + } } \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts index f089f78..3b78d81 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -13,4 +13,6 @@ import "./chat"; import "./settings"; import "./leftpanel"; import "./init"; -import "./profile"; \ No newline at end of file +import "./profile"; +import "./message-context-menu"; +import "./user-profile-dialog"; \ No newline at end of file diff --git a/frontend/src/message-context-menu.ts b/frontend/src/message-context-menu.ts new file mode 100644 index 0000000..0835984 --- /dev/null +++ b/frontend/src/message-context-menu.ts @@ -0,0 +1,437 @@ +/** + * @fileoverview Message context menu functionality + * @description Handles right-click context menu for message actions (edit, delete, reply) + * @author Cursor + * @version 1.0.0 + */ + +import { currentUser, authToken } from "./auth"; +import { websocket } from "./websocket"; +import type { Message, WebSocketMessage } from "./types"; +import { showSuccess, showError } from "./utils/notification"; + +/** + * Message context menu class + * @class MessageContextMenu + */ +class MessageContextMenu { + private menu: HTMLElement | null = null; + private editDialog: HTMLElement | null = null; + private replyDialog: HTMLElement | null = null; + private currentMessage: Message | null = null; + + constructor() { + this.createMenu(); + this.createDialogs(); + this.bindEvents(); + } + + /** + * Creates the context menu element + * @private + */ + private createMenu(): void { + this.menu = document.createElement('div'); + this.menu.className = 'message-context-menu'; + this.menu.innerHTML = ` +
+ reply + Reply +
+
+ edit + Edit +
+
+ delete + Delete +
+ `; + document.body.appendChild(this.menu); + } + + /** + * Creates the dialogs using MDUI components + * @private + */ + private createDialogs(): void { + // Create edit dialog + this.editDialog = document.createElement('mdui-dialog'); + this.editDialog.id = 'edit-message-dialog'; + this.editDialog.setAttribute('close-on-overlay-click', ''); + this.editDialog.setAttribute('close-on-esc', ''); + this.editDialog.innerHTML = ` +
+

Edit Message

+ + +
+ Cancel + Save +
+
+ `; + document.body.appendChild(this.editDialog); + + // Create reply dialog + this.replyDialog = document.createElement('mdui-dialog'); + this.replyDialog.id = 'reply-message-dialog'; + this.replyDialog.setAttribute('close-on-overlay-click', ''); + this.replyDialog.setAttribute('close-on-esc', ''); + this.replyDialog.innerHTML = ` +
+

Reply to Message

+
+ + +
+ Cancel + Send Reply +
+
+ `; + document.body.appendChild(this.replyDialog); + } + + /** + * Binds event listeners + * @private + */ + private bindEvents(): void { + // Context menu events + this.menu?.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const action = target.closest('.context-menu-item')?.getAttribute('data-action'); + + if (action && this.currentMessage) { + this.handleAction(action, this.currentMessage); + } + }); + + // Close menu when clicking outside + document.addEventListener('click', (e) => { + if (!this.menu?.contains(e.target as Node)) { + this.hide(); + } + }); + + // Edit dialog events + const editCancelBtn = this.editDialog?.querySelector('#edit-cancel'); + const editSaveBtn = this.editDialog?.querySelector('#edit-save'); + + editCancelBtn?.addEventListener('click', () => this.hideEditDialog()); + editSaveBtn?.addEventListener('click', () => this.saveEdit()); + + // Reply dialog events + const replyCancelBtn = this.replyDialog?.querySelector('#reply-cancel'); + const replySendBtn = this.replyDialog?.querySelector('#reply-send'); + + replyCancelBtn?.addEventListener('click', () => this.hideReplyDialog()); + replySendBtn?.addEventListener('click', () => this.sendReply()); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.hide(); + this.hideEditDialog(); + this.hideReplyDialog(); + } + }); + } + + /** + * Shows the context menu at the specified position + * @param {Message} message - The message to show menu for + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + */ + public show(message: Message, x: number, y: number): void { + if (!this.menu) return; + + this.currentMessage = message; + + // Only show edit and delete for own messages + const editItem = this.menu.querySelector('[data-action="edit"]') as HTMLElement; + const deleteItem = this.menu.querySelector('[data-action="delete"]') as HTMLElement; + + if (message.username === currentUser?.username) { + editItem.style.display = 'flex'; + deleteItem.style.display = 'flex'; + } else { + editItem.style.display = 'none'; + deleteItem.style.display = 'none'; + } + + // Calculate menu position + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Default menu size estimates + const menuWidth = 150; + const menuHeight = 120; + + let adjustedX = x; + let adjustedY = y; + + // Adjust horizontal position if menu would go off-screen + if (x + menuWidth > viewportWidth) { + adjustedX = x - menuWidth; + } + + // Adjust vertical position if menu would go off-screen + if (y + menuHeight > viewportHeight) { + adjustedY = y - menuHeight; + } + + // Ensure menu doesn't go off the left or top edges + adjustedX = Math.max(0, adjustedX); + adjustedY = Math.max(0, adjustedY); + + this.menu.style.left = `${adjustedX}px`; + this.menu.style.top = `${adjustedY}px`; + this.menu.style.display = 'block'; + } + + /** + * Hides the context menu + */ + public hide(): void { + if (this.menu) { + this.menu.style.display = 'none'; + } + this.currentMessage = null; + } + + /** + * Handles context menu actions + * @param {string} action - The action to perform + * @param {Message} message - The message to act on + * @private + */ + private handleAction(action: string, message: Message): void { + this.hide(); + + switch (action) { + case 'edit': + this.showEditDialog(message); + break; + case 'delete': + this.deleteMessage(message); + break; + case 'reply': + this.showReplyDialog(message); + break; + } + } + + /** + * Shows the edit dialog + * @param {Message} message - The message to edit + * @private + */ + private showEditDialog(message: Message): void { + if (!this.editDialog) return; + + const textField = this.editDialog.querySelector('#edit-message-input') as any; + if (textField) { + textField.value = message.content; + } + + this.currentMessage = message; + (this.editDialog as any).open = true; + + // Focus the text field + setTimeout(() => { + textField?.focus(); + }, 100); + } + + /** + * Hides the edit dialog + * @private + */ + private hideEditDialog(): void { + if (this.editDialog) { + (this.editDialog as any).open = false; + } + } + + /** + * Saves the edited message + * @private + */ + private saveEdit(): void { + if (!this.currentMessage || !this.editDialog) return; + + const textField = this.editDialog.querySelector('#edit-message-input') as any; + const newContent = textField?.value?.trim() || ''; + + if (!newContent) { + showError('Message cannot be empty'); + return; + } + + const payload: WebSocketMessage = { + type: "editMessage", + data: { + message_id: this.currentMessage.id, + content: newContent + }, + credentials: { + scheme: "Bearer", + credentials: authToken! + } + }; + + let callback: ((e: MessageEvent) => void) | null = null; + callback = (e) => { + websocket.removeEventListener("message", callback!); + const response: WebSocketMessage = JSON.parse(e.data); + + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Message edited successfully'); + this.hideEditDialog(); + } + }; + websocket.addEventListener("message", callback); + websocket.send(JSON.stringify(payload)); + } + + /** + * Shows the reply dialog + * @param {Message} message - The message to reply to + * @private + */ + private showReplyDialog(message: Message): void { + if (!this.replyDialog) return; + + const preview = this.replyDialog.querySelector('#reply-preview') as HTMLElement; + if (preview) { + preview.innerHTML = ` +
+ ${message.username}: ${message.content} +
+ `; + } + + this.currentMessage = message; + (this.replyDialog as any).open = true; + + // Focus the text field + setTimeout(() => { + const textField = this.replyDialog?.querySelector('#reply-message-input') as any; + textField?.focus(); + }, 100); + } + + /** + * Hides the reply dialog + * @private + */ + private hideReplyDialog(): void { + if (this.replyDialog) { + (this.replyDialog as any).open = false; + } + } + + /** + * Sends the reply message + * @private + */ + private sendReply(): void { + if (!this.currentMessage || !this.replyDialog) return; + + const textField = this.replyDialog.querySelector('#reply-message-input') as any; + const content = textField?.value?.trim() || ''; + + if (!content) { + showError('Reply cannot be empty'); + return; + } + + const payload: WebSocketMessage = { + type: "replyMessage", + data: { + content: content, + reply_to_id: this.currentMessage.id + }, + credentials: { + scheme: "Bearer", + credentials: authToken! + } + }; + + let callback: ((e: MessageEvent) => void) | null = null; + callback = (e) => { + websocket.removeEventListener("message", callback!); + const response: WebSocketMessage = JSON.parse(e.data); + + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Reply sent successfully'); + this.hideReplyDialog(); + if (textField) { + textField.value = ''; + } + } + }; + websocket.addEventListener("message", callback); + websocket.send(JSON.stringify(payload)); + } + + /** + * Deletes a message + * @param {Message} message - The message to delete + * @private + */ + private deleteMessage(message: Message): void { + if (!confirm('Are you sure you want to delete this message?')) { + return; + } + + const payload: WebSocketMessage = { + type: "deleteMessage", + data: { + message_id: message.id + }, + credentials: { + scheme: "Bearer", + credentials: authToken! + } + }; + + let callback: ((e: MessageEvent) => void) | null = null; + callback = (e) => { + websocket.removeEventListener("message", callback!); + const response: WebSocketMessage = JSON.parse(e.data); + + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Message deleted successfully'); + } + }; + websocket.addEventListener("message", callback); + websocket.send(JSON.stringify(payload)); + } +} + +// Export singleton instance +export const messageContextMenu = new MessageContextMenu(); diff --git a/frontend/src/profile/api.ts b/frontend/src/profile/api.ts index 32c8220..d446bca 100644 --- a/frontend/src/profile/api.ts +++ b/frontend/src/profile/api.ts @@ -100,3 +100,33 @@ export async function updateProfile(data: Partial): Promise} True if update was successful, false otherwise + * @example + * const success = await updateBio('My new bio text'); + * if (success) { + * console.log('Bio updated successfully'); + * } + */ +export async function updateBio(bio: string): Promise { + try { + const response = await fetch('/api/user/bio', { + method: 'PUT', + headers: { + ...getAuthHeaders(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ bio }) + }); + + return response.ok; + } catch (error) { + console.error('Error updating bio:', error); + return false; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index abbecc2..a4b8077 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -40,16 +40,20 @@ export interface Size2D { * @property {string} username - Username of the message sender * @property {string} content - Message content * @property {boolean} is_read - Whether the message has been read + * @property {boolean} is_edited - Whether the message has been edited * @property {string} timestamp - ISO timestamp of the message * @property {string} [profile_picture] - URL to sender's profile picture + * @property {Message} [reply_to] - The message this is replying to */ export interface Message { id: number; username: string; content: string; is_read: boolean; + is_edited: boolean; timestamp: string; profile_picture?: string; + reply_to?: Message; } /** @@ -69,6 +73,7 @@ export interface Messages { * @property {string} last_seen - ISO timestamp of last activity * @property {boolean} online - Whether the user is currently online * @property {string} username - Username + * @property {string} [bio] - User biography */ export interface User { id: number; @@ -76,6 +81,28 @@ export interface User { last_seen: string; online: boolean; username: string; + bio?: string; +} + +/** + * User profile response structure + * @interface UserProfile + * @property {number} id - Unique user identifier + * @property {string} username - Username + * @property {string} [profile_picture] - URL to user's profile picture + * @property {string} [bio] - User biography + * @property {boolean} online - Whether the user is currently online + * @property {string} last_seen - ISO timestamp of last activity + * @property {string} created_at - ISO timestamp of account creation + */ +export interface UserProfile { + id: number; + username: string; + profile_picture?: string; + bio?: string; + online: boolean; + last_seen: string; + created_at: string; } // ---------- diff --git a/frontend/src/user-profile-dialog.ts b/frontend/src/user-profile-dialog.ts new file mode 100644 index 0000000..e75bc64 --- /dev/null +++ b/frontend/src/user-profile-dialog.ts @@ -0,0 +1,282 @@ +/** + * @fileoverview User profile dialog functionality + * @description Handles displaying user profiles in a modal dialog + * @author Cursor + * @version 1.0.0 + */ + +import { getAuthHeaders, currentUser } from "./auth"; +import { API_BASE_URL } from "./config"; +import type { UserProfile } from "./types"; +import { showError, showSuccess } from "./utils/notification"; +import { formatTime } from "./utils/utils"; + +/** + * User profile dialog class + * @class UserProfileDialog + */ +class UserProfileDialog { + private dialog: HTMLElement | null = null; + private currentProfile: UserProfile | null = null; + private isOwnProfile: boolean = false; + + constructor() { + this.createDialog(); + this.bindEvents(); + } + + /** + * Creates the profile dialog using MDUI components + * @private + */ + private createDialog(): void { + this.dialog = document.createElement('mdui-dialog'); + this.dialog.id = 'user-profile-dialog'; + this.dialog.setAttribute('close-on-overlay-click', ''); + this.dialog.setAttribute('close-on-esc', ''); + this.dialog.innerHTML = ` +
+
+ Profile Picture +
+
+
+

+
+
+
+ +
+ + + +
+
+
+ Member since: + +
+
+ Last seen: + +
+
+
+
+ `; + document.body.appendChild(this.dialog); + } + + /** + * Binds event listeners + * @private + */ + private bindEvents(): void { + // Edit bio events + const editBioBtn = this.dialog?.querySelector('#edit-bio-btn'); + const saveBioBtn = this.dialog?.querySelector('#save-bio-btn'); + const cancelBioBtn = this.dialog?.querySelector('#cancel-bio-btn'); + + editBioBtn?.addEventListener('click', () => this.startEditBio()); + saveBioBtn?.addEventListener('click', () => this.saveBio()); + cancelBioBtn?.addEventListener('click', () => this.cancelEditBio()); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.dialog?.getAttribute('open') !== null) { + this.hide(); + } + }); + } + + /** + * Shows the profile dialog for a specific user + * @param {string} username - Username to show profile for + */ + public async show(username: string): Promise { + if (!this.dialog) return; + + try { + const response = await fetch(`${API_BASE_URL}/user/${username}`, { + headers: getAuthHeaders() + }); + + if (!response.ok) { + throw new Error('Failed to load user profile'); + } + + const profile: UserProfile = await response.json(); + this.currentProfile = profile; + this.isOwnProfile = profile.username === currentUser?.username; + this.populateDialog(profile); + (this.dialog as any).open = true; + + } catch (error) { + showError('Failed to load user profile'); + console.error('Error loading user profile:', error); + } + } + + /** + * Populates the dialog with user data + * @param {UserProfile} profile - User profile data + * @private + */ + private populateDialog(profile: UserProfile): void { + if (!this.dialog) return; + + // Profile picture + const profilePic = this.dialog.querySelector('.profile-picture') as HTMLImageElement; + profilePic.src = profile.profile_picture || './src/images/default-avatar.png'; + profilePic.onerror = () => { + profilePic.src = './src/images/default-avatar.png'; + }; + + // Username + const usernameEl = this.dialog.querySelector('.username') as HTMLElement; + usernameEl.textContent = profile.username; + + // Online status + const onlineStatus = this.dialog.querySelector('.online-status') as HTMLElement; + if (profile.online) { + onlineStatus.innerHTML = ' Online'; + onlineStatus.className = 'online-status online'; + } else { + onlineStatus.innerHTML = ` Last seen ${formatTime(profile.last_seen)}`; + onlineStatus.className = 'online-status offline'; + } + + // Bio + const bioDisplay = this.dialog.querySelector('.bio-display') as HTMLElement; + const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; + + if (profile.bio) { + bioDisplay.textContent = profile.bio; + } else { + bioDisplay.textContent = this.isOwnProfile ? 'No bio yet. Click "Edit Bio" to add one!' : 'No bio available.'; + } + + if (bioEdit) { + bioEdit.value = profile.bio || ''; + } + + // Stats + const memberSince = this.dialog.querySelector('.member-since') as HTMLElement; + const lastSeen = this.dialog.querySelector('.last-seen') as HTMLElement; + + memberSince.textContent = formatTime(profile.created_at); + lastSeen.textContent = formatTime(profile.last_seen); + + // Show/hide edit button for own profile + const editBioBtn = this.dialog.querySelector('#edit-bio-btn') as HTMLElement; + editBioBtn.style.display = this.isOwnProfile ? 'block' : 'none'; + } + + /** + * Starts editing the bio + * @private + */ + private startEditBio(): void { + if (!this.dialog) return; + + const bioDisplay = this.dialog.querySelector('.bio-display') as HTMLElement; + const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; + const bioActions = this.dialog.querySelector('.bio-actions') as HTMLElement; + const editBioBtn = this.dialog.querySelector('#edit-bio-btn') as HTMLElement; + + bioDisplay.style.display = 'none'; + if (bioEdit) bioEdit.style.display = 'block'; + bioActions.style.display = 'flex'; + editBioBtn.style.display = 'none'; + + if (bioEdit) { + bioEdit.focus(); + bioEdit.setSelectionRange(bioEdit.value.length, bioEdit.value.length); + } + } + + /** + * Saves the bio + * @private + */ + private async saveBio(): Promise { + if (!this.dialog || !this.currentProfile) return; + + const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; + const newBio = bioEdit?.value?.trim() || ''; + + try { + const response = await fetch(`${API_BASE_URL}/user/bio`, { + method: 'PUT', + headers: { + ...getAuthHeaders(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ bio: newBio }) + }); + + if (!response.ok) { + throw new Error('Failed to update bio'); + } + + const result = await response.json(); + this.currentProfile.bio = result.bio; + this.populateDialog(this.currentProfile); + this.cancelEditBio(); + showSuccess('Bio updated successfully'); + + } catch (error) { + showError('Failed to update bio'); + console.error('Error updating bio:', error); + } + } + + /** + * Cancels bio editing + * @private + */ + private cancelEditBio(): void { + if (!this.dialog) return; + + const bioDisplay = this.dialog.querySelector('.bio-display') as HTMLElement; + const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; + const bioActions = this.dialog.querySelector('.bio-actions') as HTMLElement; + const editBioBtn = this.dialog.querySelector('#edit-bio-btn') as HTMLElement; + + bioDisplay.style.display = 'block'; + if (bioEdit) bioEdit.style.display = 'none'; + bioActions.style.display = 'none'; + editBioBtn.style.display = this.isOwnProfile ? 'block' : 'none'; + + // Reset bio edit to current value + if (bioEdit) { + bioEdit.value = this.currentProfile?.bio || ''; + } + } + + /** + * Hides the dialog + */ + public hide(): void { + if (this.dialog) { + (this.dialog as any).open = false; + } + this.currentProfile = null; + this.isOwnProfile = false; + } +} + +// Export singleton instance +export const userProfileDialog = new UserProfileDialog(); diff --git a/frontend/src/websocket.ts b/frontend/src/websocket.ts index 57460e9..b514ca0 100644 --- a/frontend/src/websocket.ts +++ b/frontend/src/websocket.ts @@ -5,10 +5,9 @@ * @version 1.0.0 */ -import { currentUser } from "./auth"; -import { addMessage } from "./chat"; +import { handleWebSocketMessage } from "./chat"; import { API_FULL_BASE_URL } from "./config"; -import type { WebSocketMessage, Message } from "./types"; +import type { WebSocketMessage } from "./types"; import { delay } from "./utils/utils"; /** @@ -25,7 +24,7 @@ function create(): WebSocket { * Global WebSocket instance * @type {WebSocket} */ -export let websocket = create(); +export let websocket: WebSocket = create(); // -------------- // Initialization @@ -33,13 +32,7 @@ export let websocket = create(); websocket.addEventListener("message", (e) => { const message: WebSocketMessage = JSON.parse(e.data); - switch (message.type) { - case "newMessage": { - const newMessage: Message = message.data; - addMessage(newMessage, newMessage.username == currentUser!.username); - break; - } - } + handleWebSocketMessage(message); }); websocket.addEventListener("error", async () => { From a2a3081e42dccf9c915509d375f693ecb7d9cb63 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 23 Aug 2025 15:37:48 +0300 Subject: [PATCH 7/7] Convert components to static HTML --- frontend/index.html | 85 ++- frontend/src/chat.ts | 10 +- frontend/src/css/_chat.scss | 125 +--- frontend/src/css/_profile.scss | 135 ++++- frontend/src/css/common/_components.scss | 4 - frontend/src/message-context-menu.ts | 715 ++++++++++------------- frontend/src/user-profile-dialog.ts | 445 ++++++-------- 7 files changed, 721 insertions(+), 798 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 17d1d18..5f5a6e8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -194,7 +194,7 @@
-
+
Ваше фото @@ -320,6 +320,89 @@
+ +
+
+ reply + Reply +
+
+ edit + Edit +
+
+ delete + Delete +
+
+ + +
+

Edit Message

+ + +
+ Cancel + Save +
+
+
+ + +
+

Reply to Message

+
+ + +
+ Cancel + Send Reply +
+
+
+ + +
+
+ Profile Picture +
+
+
+

+
+
+
+ +
+
+
+
+ Member since: + +
+
+ Last seen: + +
+
+
+
+
+ \ No newline at end of file diff --git a/frontend/src/chat.ts b/frontend/src/chat.ts index fe6d1ce..834a3a7 100644 --- a/frontend/src/chat.ts +++ b/frontend/src/chat.ts @@ -10,8 +10,8 @@ import { API_BASE_URL } from "./config"; import { websocket } from "./websocket"; import type { Message, Messages, WebSocketMessage } from "./types"; import { formatTime } from "./utils/utils"; -import { messageContextMenu } from "./message-context-menu"; -import { userProfileDialog } from "./user-profile-dialog"; +import { show as showContextMenu } from "./message-context-menu"; +import { show as showUserProfileDialog } from "./user-profile-dialog"; /** * Adds a new message to the chat interface @@ -50,7 +50,7 @@ export function addMessage(message: Message, isAuthor: boolean): void { // Add click handler to profile picture profileImg.style.cursor = 'pointer'; profileImg.addEventListener('click', () => { - userProfileDialog.show(message.username); + showUserProfileDialog(message.username); }); profilePicDiv.appendChild(profileImg); @@ -65,7 +65,7 @@ export function addMessage(message: Message, isAuthor: boolean): void { // Add click handler to username usernameDiv.style.cursor = 'pointer'; usernameDiv.addEventListener('click', () => { - userProfileDialog.show(message.username); + showUserProfileDialog(message.username); }); messageInner.appendChild(usernameDiv); @@ -111,7 +111,7 @@ export function addMessage(message: Message, isAuthor: boolean): void { // Add right-click context menu messageDiv.addEventListener('contextmenu', (e) => { e.preventDefault(); - messageContextMenu.show(message, e.clientX, e.clientY); + showContextMenu(message, e.clientX, e.clientY); }); // Прокрутка к новому сообщению diff --git a/frontend/src/css/_chat.scss b/frontend/src/css/_chat.scss index e2f847c..ccdb043 100644 --- a/frontend/src/css/_chat.scss +++ b/frontend/src/css/_chat.scss @@ -320,7 +320,7 @@ } // Message Context Menu -.message-context-menu { +#message-context-menu { position: fixed; background-color: $color-dark-surface-container; border-radius: 8px; @@ -393,126 +393,3 @@ } } -// User profile dialog content styles -.profile-dialog-content { - padding: 1.5rem; - display: flex; - gap: 1.5rem; - - .profile-picture-section { - flex-shrink: 0; - - .profile-picture { - width: 80px; - height: 80px; - border-radius: 50%; - object-fit: cover; - border: 2px solid $color-dark-outline; - } - } - - .profile-info { - flex: 1; - display: flex; - flex-direction: column; - gap: 1rem; - - .username-section { - display: flex; - align-items: center; - gap: 0.75rem; - - .username { - margin: 0; - color: $color-dark-on-surface; - font-size: 1.1rem; - font-weight: 600; - } - - .online-status { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.85rem; - padding: 0.25rem 0.5rem; - border-radius: 12px; - font-weight: 500; - - &.online { - color: $success; - background-color: rgba(76, 175, 80, 0.1); - - .online-indicator { - width: 8px; - height: 8px; - border-radius: 50%; - background-color: $success; - } - } - - &.offline { - color: $color-dark-on-surface-variant; - background-color: rgba(255, 255, 255, 0.05); - - .offline-indicator { - width: 8px; - height: 8px; - border-radius: 50%; - background-color: $color-dark-on-surface-variant; - } - } - } - } - - .bio-section { - label { - display: block; - color: $color-dark-on-surface-variant; - font-size: 0.85rem; - font-weight: 500; - margin-bottom: 0.5rem; - } - - .bio-display { - color: $color-dark-on-surface; - font-size: 0.9rem; - line-height: 1.4; - padding: 0.75rem; - background-color: $color-dark-surface; - border-radius: 8px; - border: 1px solid $color-dark-outline; - min-height: 60px; - } - - .bio-actions { - display: flex; - gap: 0.5rem; - margin-top: 0.5rem; - } - } - - .profile-stats { - display: flex; - flex-direction: column; - gap: 0.5rem; - - .stat { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.5rem 0; - - .stat-label { - color: $color-dark-on-surface-variant; - font-size: 0.85rem; - } - - .stat-value { - color: $color-dark-on-surface; - font-size: 0.85rem; - font-weight: 500; - } - } - } - } -} \ No newline at end of file diff --git a/frontend/src/css/_profile.scss b/frontend/src/css/_profile.scss index 76fe006..518c983 100644 --- a/frontend/src/css/_profile.scss +++ b/frontend/src/css/_profile.scss @@ -1,13 +1,11 @@ @use "common/colors" as *; @use "common/material" as *; -#profile-dialog { - .profile-dialog-content { - display: flex; - flex-direction: column; - gap: 24px; - min-width: 400px; - } +#profile-dialog .content { + display: flex; + flex-direction: column; + gap: 24px; + min-width: 400px; .header-top { display: flex; @@ -69,6 +67,129 @@ } } +// User profile dialog content styles +#user-profile-dialog .content { + display: flex; + gap: 1.5rem; + + .profile-picture-section { + flex-shrink: 0; + + .profile-picture { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + border: 2px solid $color-dark-outline; + } + } + + .profile-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; + + .username-section { + display: flex; + align-items: center; + gap: 0.75rem; + + .username { + margin: 0; + color: $color-dark-on-surface; + font-size: 1.1rem; + font-weight: 600; + } + + .online-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-weight: 500; + + &.online { + color: $success; + background-color: rgba(76, 175, 80, 0.1); + + .online-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: $success; + } + } + + &.offline { + color: $color-dark-on-surface-variant; + background-color: rgba(255, 255, 255, 0.05); + + .offline-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: $color-dark-on-surface-variant; + } + } + } + } + + .bio-section { + label { + display: block; + color: $color-dark-on-surface-variant; + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 0.5rem; + } + + .bio-display { + color: $color-dark-on-surface; + font-size: 0.9rem; + line-height: 1.4; + padding: 0.75rem; + background-color: $color-dark-surface; + border-radius: 8px; + border: 1px solid $color-dark-outline; + min-height: 60px; + } + + .bio-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + } + } + + .profile-stats { + display: flex; + flex-direction: column; + gap: 0.5rem; + + .stat { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + + .stat-label { + color: $color-dark-on-surface-variant; + font-size: 0.85rem; + } + + .stat-value { + color: $color-dark-on-surface; + font-size: 0.85rem; + font-weight: 500; + } + } + } + } +} + // Cropper Dialog Styles #cropper-dialog { .cropper-dialog-content { diff --git a/frontend/src/css/common/_components.scss b/frontend/src/css/common/_components.scss index 0307fab..982ba92 100644 --- a/frontend/src/css/common/_components.scss +++ b/frontend/src/css/common/_components.scss @@ -4,10 +4,6 @@ text-align: center; } -.mt-3 { - margin-top: 1rem; -} - .alert { padding: 0.8rem 1rem; border-radius: 6px; diff --git a/frontend/src/message-context-menu.ts b/frontend/src/message-context-menu.ts index 0835984..aece9f8 100644 --- a/frontend/src/message-context-menu.ts +++ b/frontend/src/message-context-menu.ts @@ -10,428 +10,339 @@ import { websocket } from "./websocket"; import type { Message, WebSocketMessage } from "./types"; import { showSuccess, showError } from "./utils/notification"; + +let menu = document.getElementById("message-context-menu")!; +let editDialog = document.getElementById("edit-message-dialog"); +let replyDialog = document.getElementById("reply-message-dialog"); +let currentMessage: Message | null = null; + +function init() { + bindEvents(); +} + /** - * Message context menu class - * @class MessageContextMenu + * Binds event listeners + * @private */ -class MessageContextMenu { - private menu: HTMLElement | null = null; - private editDialog: HTMLElement | null = null; - private replyDialog: HTMLElement | null = null; - private currentMessage: Message | null = null; - - constructor() { - this.createMenu(); - this.createDialogs(); - this.bindEvents(); - } - - /** - * Creates the context menu element - * @private - */ - private createMenu(): void { - this.menu = document.createElement('div'); - this.menu.className = 'message-context-menu'; - this.menu.innerHTML = ` -
- reply - Reply -
-
- edit - Edit -
-
- delete - Delete -
- `; - document.body.appendChild(this.menu); - } - - /** - * Creates the dialogs using MDUI components - * @private - */ - private createDialogs(): void { - // Create edit dialog - this.editDialog = document.createElement('mdui-dialog'); - this.editDialog.id = 'edit-message-dialog'; - this.editDialog.setAttribute('close-on-overlay-click', ''); - this.editDialog.setAttribute('close-on-esc', ''); - this.editDialog.innerHTML = ` -
-

Edit Message

- - -
- Cancel - Save -
-
- `; - document.body.appendChild(this.editDialog); - - // Create reply dialog - this.replyDialog = document.createElement('mdui-dialog'); - this.replyDialog.id = 'reply-message-dialog'; - this.replyDialog.setAttribute('close-on-overlay-click', ''); - this.replyDialog.setAttribute('close-on-esc', ''); - this.replyDialog.innerHTML = ` -
-

Reply to Message

-
- - -
- Cancel - Send Reply -
-
- `; - document.body.appendChild(this.replyDialog); - } - - /** - * Binds event listeners - * @private - */ - private bindEvents(): void { - // Context menu events - this.menu?.addEventListener('click', (e) => { - const target = e.target as HTMLElement; - const action = target.closest('.context-menu-item')?.getAttribute('data-action'); - - if (action && this.currentMessage) { - this.handleAction(action, this.currentMessage); - } - }); - - // Close menu when clicking outside - document.addEventListener('click', (e) => { - if (!this.menu?.contains(e.target as Node)) { - this.hide(); - } - }); - - // Edit dialog events - const editCancelBtn = this.editDialog?.querySelector('#edit-cancel'); - const editSaveBtn = this.editDialog?.querySelector('#edit-save'); - - editCancelBtn?.addEventListener('click', () => this.hideEditDialog()); - editSaveBtn?.addEventListener('click', () => this.saveEdit()); - - // Reply dialog events - const replyCancelBtn = this.replyDialog?.querySelector('#reply-cancel'); - const replySendBtn = this.replyDialog?.querySelector('#reply-send'); - - replyCancelBtn?.addEventListener('click', () => this.hideReplyDialog()); - replySendBtn?.addEventListener('click', () => this.sendReply()); - - // Keyboard shortcuts - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - this.hide(); - this.hideEditDialog(); - this.hideReplyDialog(); - } - }); - } - - /** - * Shows the context menu at the specified position - * @param {Message} message - The message to show menu for - * @param {number} x - X coordinate - * @param {number} y - Y coordinate - */ - public show(message: Message, x: number, y: number): void { - if (!this.menu) return; - - this.currentMessage = message; +function bindEvents(): void { + // Context menu events + menu?.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const action = target.closest('.context-menu-item')?.getAttribute('data-action'); - // Only show edit and delete for own messages - const editItem = this.menu.querySelector('[data-action="edit"]') as HTMLElement; - const deleteItem = this.menu.querySelector('[data-action="delete"]') as HTMLElement; - - if (message.username === currentUser?.username) { - editItem.style.display = 'flex'; - deleteItem.style.display = 'flex'; - } else { - editItem.style.display = 'none'; - deleteItem.style.display = 'none'; + if (action && currentMessage) { + handleAction(action, currentMessage); } + }); - // Calculate menu position - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - // Default menu size estimates - const menuWidth = 150; - const menuHeight = 120; - - let adjustedX = x; - let adjustedY = y; - - // Adjust horizontal position if menu would go off-screen - if (x + menuWidth > viewportWidth) { - adjustedX = x - menuWidth; + // Close menu when clicking outside + document.addEventListener('click', (e) => { + if (!menu?.contains(e.target as Node)) { + hide(); } + }); - // Adjust vertical position if menu would go off-screen - if (y + menuHeight > viewportHeight) { - adjustedY = y - menuHeight; + // Edit dialog events + const editCancelBtn = editDialog?.querySelector('#edit-cancel'); + const editSaveBtn = editDialog?.querySelector('#edit-save'); + + editCancelBtn?.addEventListener('click', () => hideEditDialog()); + editSaveBtn?.addEventListener('click', () => saveEdit()); + + // Reply dialog events + const replyCancelBtn = replyDialog?.querySelector('#reply-cancel'); + const replySendBtn = replyDialog?.querySelector('#reply-send'); + + replyCancelBtn?.addEventListener('click', () => hideReplyDialog()); + replySendBtn?.addEventListener('click', () => sendReply()); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + hide(); + hideEditDialog(); + hideReplyDialog(); } + }); +} - // Ensure menu doesn't go off the left or top edges - adjustedX = Math.max(0, adjustedX); - adjustedY = Math.max(0, adjustedY); +/** + * Shows the context menu at the specified position + * @param {Message} message - The message to show menu for + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + */ +export function show(message: Message, x: number, y: number): void { + if (!menu) return; - this.menu.style.left = `${adjustedX}px`; - this.menu.style.top = `${adjustedY}px`; - this.menu.style.display = 'block'; + currentMessage = message; + + // Only show edit and delete for own messages + const editItem = menu.querySelector('[data-action="edit"]') as HTMLElement; + const deleteItem = menu.querySelector('[data-action="delete"]') as HTMLElement; + + if (message.username === currentUser?.username) { + editItem.style.display = 'flex'; + deleteItem.style.display = 'flex'; + } else { + editItem.style.display = 'none'; + deleteItem.style.display = 'none'; } - /** - * Hides the context menu - */ - public hide(): void { - if (this.menu) { - this.menu.style.display = 'none'; - } - this.currentMessage = null; + // Calculate menu position + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Default menu size estimates + const menuWidth = 150; + const menuHeight = 120; + + let adjustedX = x; + let adjustedY = y; + + // Adjust horizontal position if menu would go off-screen + if (x + menuWidth > viewportWidth) { + adjustedX = x - menuWidth; } - /** - * Handles context menu actions - * @param {string} action - The action to perform - * @param {Message} message - The message to act on - * @private - */ - private handleAction(action: string, message: Message): void { - this.hide(); - - switch (action) { - case 'edit': - this.showEditDialog(message); - break; - case 'delete': - this.deleteMessage(message); - break; - case 'reply': - this.showReplyDialog(message); - break; - } + // Adjust vertical position if menu would go off-screen + if (y + menuHeight > viewportHeight) { + adjustedY = y - menuHeight; } - /** - * Shows the edit dialog - * @param {Message} message - The message to edit - * @private - */ - private showEditDialog(message: Message): void { - if (!this.editDialog) return; + // Ensure menu doesn't go off the left or top edges + adjustedX = Math.max(0, adjustedX); + adjustedY = Math.max(0, adjustedY); - const textField = this.editDialog.querySelector('#edit-message-input') as any; - if (textField) { - textField.value = message.content; - } - - this.currentMessage = message; - (this.editDialog as any).open = true; - - // Focus the text field - setTimeout(() => { - textField?.focus(); - }, 100); + menu.style.left = `${adjustedX}px`; + menu.style.top = `${adjustedY}px`; + menu.style.display = 'block'; +} + +/** + * Hides the context menu + */ +export function hide(): void { + if (menu) { + menu.style.display = 'none'; } + currentMessage = null; +} - /** - * Hides the edit dialog - * @private - */ - private hideEditDialog(): void { - if (this.editDialog) { - (this.editDialog as any).open = false; - } - } +/** + * Handles context menu actions + * @param {string} action - The action to perform + * @param {Message} message - The message to act on + * @private + */ +function handleAction(action: string, message: Message): void { + hide(); - /** - * Saves the edited message - * @private - */ - private saveEdit(): void { - if (!this.currentMessage || !this.editDialog) return; - - const textField = this.editDialog.querySelector('#edit-message-input') as any; - const newContent = textField?.value?.trim() || ''; - - if (!newContent) { - showError('Message cannot be empty'); - return; - } - - const payload: WebSocketMessage = { - type: "editMessage", - data: { - message_id: this.currentMessage.id, - content: newContent - }, - credentials: { - scheme: "Bearer", - credentials: authToken! - } - }; - - let callback: ((e: MessageEvent) => void) | null = null; - callback = (e) => { - websocket.removeEventListener("message", callback!); - const response: WebSocketMessage = JSON.parse(e.data); - - if (response.error) { - showError(response.error.detail); - } else { - showSuccess('Message edited successfully'); - this.hideEditDialog(); - } - }; - websocket.addEventListener("message", callback); - websocket.send(JSON.stringify(payload)); - } - - /** - * Shows the reply dialog - * @param {Message} message - The message to reply to - * @private - */ - private showReplyDialog(message: Message): void { - if (!this.replyDialog) return; - - const preview = this.replyDialog.querySelector('#reply-preview') as HTMLElement; - if (preview) { - preview.innerHTML = ` -
- ${message.username}: ${message.content} -
- `; - } - - this.currentMessage = message; - (this.replyDialog as any).open = true; - - // Focus the text field - setTimeout(() => { - const textField = this.replyDialog?.querySelector('#reply-message-input') as any; - textField?.focus(); - }, 100); - } - - /** - * Hides the reply dialog - * @private - */ - private hideReplyDialog(): void { - if (this.replyDialog) { - (this.replyDialog as any).open = false; - } - } - - /** - * Sends the reply message - * @private - */ - private sendReply(): void { - if (!this.currentMessage || !this.replyDialog) return; - - const textField = this.replyDialog.querySelector('#reply-message-input') as any; - const content = textField?.value?.trim() || ''; - - if (!content) { - showError('Reply cannot be empty'); - return; - } - - const payload: WebSocketMessage = { - type: "replyMessage", - data: { - content: content, - reply_to_id: this.currentMessage.id - }, - credentials: { - scheme: "Bearer", - credentials: authToken! - } - }; - - let callback: ((e: MessageEvent) => void) | null = null; - callback = (e) => { - websocket.removeEventListener("message", callback!); - const response: WebSocketMessage = JSON.parse(e.data); - - if (response.error) { - showError(response.error.detail); - } else { - showSuccess('Reply sent successfully'); - this.hideReplyDialog(); - if (textField) { - textField.value = ''; - } - } - }; - websocket.addEventListener("message", callback); - websocket.send(JSON.stringify(payload)); - } - - /** - * Deletes a message - * @param {Message} message - The message to delete - * @private - */ - private deleteMessage(message: Message): void { - if (!confirm('Are you sure you want to delete this message?')) { - return; - } - - const payload: WebSocketMessage = { - type: "deleteMessage", - data: { - message_id: message.id - }, - credentials: { - scheme: "Bearer", - credentials: authToken! - } - }; - - let callback: ((e: MessageEvent) => void) | null = null; - callback = (e) => { - websocket.removeEventListener("message", callback!); - const response: WebSocketMessage = JSON.parse(e.data); - - if (response.error) { - showError(response.error.detail); - } else { - showSuccess('Message deleted successfully'); - } - }; - websocket.addEventListener("message", callback); - websocket.send(JSON.stringify(payload)); + switch (action) { + case 'edit': + showEditDialog(message); + break; + case 'delete': + deleteMessage(message); + break; + case 'reply': + showReplyDialog(message); + break; } } -// Export singleton instance -export const messageContextMenu = new MessageContextMenu(); +/** + * Shows the edit dialog + * @param {Message} message - The message to edit + * @private + */ +function showEditDialog(message: Message): void { + if (!editDialog) return; + + const textField = editDialog.querySelector('#edit-message-input') as any; + if (textField) { + textField.value = message.content; + } + + currentMessage = message; + (editDialog as any).open = true; + + // Focus the text field + setTimeout(() => { + textField?.focus(); + }, 100); +} + +/** + * Hides the edit dialog + * @private + */ +function hideEditDialog(): void { + if (editDialog) { + (editDialog as any).open = false; + } +} + +/** + * Saves the edited message + * @private + */ +function saveEdit(): void { + if (!currentMessage || !editDialog) return; + + const textField = editDialog.querySelector('#edit-message-input') as any; + const newContent = textField?.value?.trim() || ''; + + if (!newContent) { + showError('Message cannot be empty'); + return; + } + + const payload: WebSocketMessage = { + type: "editMessage", + data: { + message_id: currentMessage.id, + content: newContent + }, + credentials: { + scheme: "Bearer", + credentials: authToken! + } + }; + + let callback: ((e: MessageEvent) => void) | null = null; + callback = (e) => { + websocket.removeEventListener("message", callback!); + const response: WebSocketMessage = JSON.parse(e.data); + + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Message edited successfully'); + hideEditDialog(); + } + }; + websocket.addEventListener("message", callback); + websocket.send(JSON.stringify(payload)); +} + +/** + * Shows the reply dialog + * @param {Message} message - The message to reply to + * @private + */ +function showReplyDialog(message: Message): void { + if (!replyDialog) return; + + const preview = replyDialog.querySelector('#reply-preview') as HTMLElement; + if (preview) { + preview.innerHTML = ` +
+ ${message.username}: ${message.content} +
+ `; + } + + currentMessage = message; + (replyDialog as any).open = true; + + // Focus the text field + setTimeout(() => { + const textField = replyDialog?.querySelector('#reply-message-input') as any; + textField?.focus(); + }, 100); +} + +/** + * Hides the reply dialog + * @private + */ +function hideReplyDialog(): void { + if (replyDialog) { + (replyDialog as any).open = false; + } +} + +/** + * Sends the reply message + * @private + */ +function sendReply(): void { + if (!currentMessage || !replyDialog) return; + + const textField = replyDialog.querySelector('#reply-message-input') as any; + const content = textField?.value?.trim() || ''; + + if (!content) { + showError('Reply cannot be empty'); + return; + } + + const payload: WebSocketMessage = { + type: "replyMessage", + data: { + content: content, + reply_to_id: currentMessage.id + }, + credentials: { + scheme: "Bearer", + credentials: authToken! + } + }; + + let callback: ((e: MessageEvent) => void) | null = null; + callback = (e) => { + websocket.removeEventListener("message", callback!); + const response: WebSocketMessage = JSON.parse(e.data); + + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Reply sent successfully'); + hideReplyDialog(); + if (textField) { + textField.value = ''; + } + } + }; + websocket.addEventListener("message", callback); + websocket.send(JSON.stringify(payload)); +} + +/** + * Deletes a message + * @param {Message} message - The message to delete + * @private + */ +function deleteMessage(message: Message): void { + if (!confirm('Are you sure you want to delete this message?')) { + return; + } + + const payload: WebSocketMessage = { + type: "deleteMessage", + data: { + message_id: message.id + }, + credentials: { + scheme: "Bearer", + credentials: authToken! + } + }; + + let callback: ((e: MessageEvent) => void) | null = null; + callback = (e) => { + websocket.removeEventListener("message", callback!); + const response: WebSocketMessage = JSON.parse(e.data); + + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Message deleted successfully'); + } + }; + websocket.addEventListener("message", callback); + websocket.send(JSON.stringify(payload)); +} + +init(); \ No newline at end of file diff --git a/frontend/src/user-profile-dialog.ts b/frontend/src/user-profile-dialog.ts index e75bc64..bce721c 100644 --- a/frontend/src/user-profile-dialog.ts +++ b/frontend/src/user-profile-dialog.ts @@ -11,272 +11,207 @@ import type { UserProfile } from "./types"; import { showError, showSuccess } from "./utils/notification"; import { formatTime } from "./utils/utils"; + +let dialog = document.getElementById("user-profile-dialog")!; +let currentProfile: UserProfile | null = null; +let isOwnProfile: boolean = false; + +function init() { + bindEvents(); +} + /** - * User profile dialog class - * @class UserProfileDialog + * Binds event listeners + * @private */ -class UserProfileDialog { - private dialog: HTMLElement | null = null; - private currentProfile: UserProfile | null = null; - private isOwnProfile: boolean = false; +function bindEvents(): void { + // Edit bio events + const editBioBtn = dialog?.querySelector('#edit-bio-btn'); + const saveBioBtn = dialog?.querySelector('#save-bio-btn'); + const cancelBioBtn = dialog?.querySelector('#cancel-bio-btn'); - constructor() { - this.createDialog(); - this.bindEvents(); - } + editBioBtn?.addEventListener('click', () => startEditBio()); + saveBioBtn?.addEventListener('click', () => saveBio()); + cancelBioBtn?.addEventListener('click', () => cancelEditBio()); - /** - * Creates the profile dialog using MDUI components - * @private - */ - private createDialog(): void { - this.dialog = document.createElement('mdui-dialog'); - this.dialog.id = 'user-profile-dialog'; - this.dialog.setAttribute('close-on-overlay-click', ''); - this.dialog.setAttribute('close-on-esc', ''); - this.dialog.innerHTML = ` -
-
- Profile Picture -
-
-
-

-
-
-
- -
- - - -
-
-
- Member since: - -
-
- Last seen: - -
-
-
-
- `; - document.body.appendChild(this.dialog); - } + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && dialog?.getAttribute('open') !== null) { + hide(); + } + }); +} - /** - * Binds event listeners - * @private - */ - private bindEvents(): void { - // Edit bio events - const editBioBtn = this.dialog?.querySelector('#edit-bio-btn'); - const saveBioBtn = this.dialog?.querySelector('#save-bio-btn'); - const cancelBioBtn = this.dialog?.querySelector('#cancel-bio-btn'); +/** + * Shows the profile dialog for a specific user + * @param {string} username - Username to show profile for + */ +export async function show(username: string): Promise { + if (!dialog) return; - editBioBtn?.addEventListener('click', () => this.startEditBio()); - saveBioBtn?.addEventListener('click', () => this.saveBio()); - cancelBioBtn?.addEventListener('click', () => this.cancelEditBio()); - - // Keyboard shortcuts - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && this.dialog?.getAttribute('open') !== null) { - this.hide(); - } + try { + const response = await fetch(`${API_BASE_URL}/user/${username}`, { + headers: getAuthHeaders() }); - } - /** - * Shows the profile dialog for a specific user - * @param {string} username - Username to show profile for - */ - public async show(username: string): Promise { - if (!this.dialog) return; - - try { - const response = await fetch(`${API_BASE_URL}/user/${username}`, { - headers: getAuthHeaders() - }); - - if (!response.ok) { - throw new Error('Failed to load user profile'); - } - - const profile: UserProfile = await response.json(); - this.currentProfile = profile; - this.isOwnProfile = profile.username === currentUser?.username; - this.populateDialog(profile); - (this.dialog as any).open = true; - - } catch (error) { - showError('Failed to load user profile'); - console.error('Error loading user profile:', error); - } - } - - /** - * Populates the dialog with user data - * @param {UserProfile} profile - User profile data - * @private - */ - private populateDialog(profile: UserProfile): void { - if (!this.dialog) return; - - // Profile picture - const profilePic = this.dialog.querySelector('.profile-picture') as HTMLImageElement; - profilePic.src = profile.profile_picture || './src/images/default-avatar.png'; - profilePic.onerror = () => { - profilePic.src = './src/images/default-avatar.png'; - }; - - // Username - const usernameEl = this.dialog.querySelector('.username') as HTMLElement; - usernameEl.textContent = profile.username; - - // Online status - const onlineStatus = this.dialog.querySelector('.online-status') as HTMLElement; - if (profile.online) { - onlineStatus.innerHTML = ' Online'; - onlineStatus.className = 'online-status online'; - } else { - onlineStatus.innerHTML = ` Last seen ${formatTime(profile.last_seen)}`; - onlineStatus.className = 'online-status offline'; + if (!response.ok) { + throw new Error('Failed to load user profile'); } - // Bio - const bioDisplay = this.dialog.querySelector('.bio-display') as HTMLElement; - const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; - - if (profile.bio) { - bioDisplay.textContent = profile.bio; - } else { - bioDisplay.textContent = this.isOwnProfile ? 'No bio yet. Click "Edit Bio" to add one!' : 'No bio available.'; - } + const profile: UserProfile = await response.json(); + currentProfile = profile; + isOwnProfile = profile.username === currentUser?.username; + populateDialog(profile); + (dialog as any).open = true; - if (bioEdit) { - bioEdit.value = profile.bio || ''; - } - - // Stats - const memberSince = this.dialog.querySelector('.member-since') as HTMLElement; - const lastSeen = this.dialog.querySelector('.last-seen') as HTMLElement; - - memberSince.textContent = formatTime(profile.created_at); - lastSeen.textContent = formatTime(profile.last_seen); - - // Show/hide edit button for own profile - const editBioBtn = this.dialog.querySelector('#edit-bio-btn') as HTMLElement; - editBioBtn.style.display = this.isOwnProfile ? 'block' : 'none'; - } - - /** - * Starts editing the bio - * @private - */ - private startEditBio(): void { - if (!this.dialog) return; - - const bioDisplay = this.dialog.querySelector('.bio-display') as HTMLElement; - const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; - const bioActions = this.dialog.querySelector('.bio-actions') as HTMLElement; - const editBioBtn = this.dialog.querySelector('#edit-bio-btn') as HTMLElement; - - bioDisplay.style.display = 'none'; - if (bioEdit) bioEdit.style.display = 'block'; - bioActions.style.display = 'flex'; - editBioBtn.style.display = 'none'; - - if (bioEdit) { - bioEdit.focus(); - bioEdit.setSelectionRange(bioEdit.value.length, bioEdit.value.length); - } - } - - /** - * Saves the bio - * @private - */ - private async saveBio(): Promise { - if (!this.dialog || !this.currentProfile) return; - - const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; - const newBio = bioEdit?.value?.trim() || ''; - - try { - const response = await fetch(`${API_BASE_URL}/user/bio`, { - method: 'PUT', - headers: { - ...getAuthHeaders(), - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ bio: newBio }) - }); - - if (!response.ok) { - throw new Error('Failed to update bio'); - } - - const result = await response.json(); - this.currentProfile.bio = result.bio; - this.populateDialog(this.currentProfile); - this.cancelEditBio(); - showSuccess('Bio updated successfully'); - - } catch (error) { - showError('Failed to update bio'); - console.error('Error updating bio:', error); - } - } - - /** - * Cancels bio editing - * @private - */ - private cancelEditBio(): void { - if (!this.dialog) return; - - const bioDisplay = this.dialog.querySelector('.bio-display') as HTMLElement; - const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; - const bioActions = this.dialog.querySelector('.bio-actions') as HTMLElement; - const editBioBtn = this.dialog.querySelector('#edit-bio-btn') as HTMLElement; - - bioDisplay.style.display = 'block'; - if (bioEdit) bioEdit.style.display = 'none'; - bioActions.style.display = 'none'; - editBioBtn.style.display = this.isOwnProfile ? 'block' : 'none'; - - // Reset bio edit to current value - if (bioEdit) { - bioEdit.value = this.currentProfile?.bio || ''; - } - } - - /** - * Hides the dialog - */ - public hide(): void { - if (this.dialog) { - (this.dialog as any).open = false; - } - this.currentProfile = null; - this.isOwnProfile = false; + } catch (error) { + showError('Failed to load user profile'); + console.error('Error loading user profile:', error); } } -// Export singleton instance -export const userProfileDialog = new UserProfileDialog(); +/** + * Populates the dialog with user data + * @param {UserProfile} profile - User profile data + * @private + */ +function populateDialog(profile: UserProfile): void { + if (!dialog) return; + + // Profile picture + const profilePic = dialog.querySelector('.profile-picture') as HTMLImageElement; + profilePic.src = profile.profile_picture || './src/images/default-avatar.png'; + profilePic.addEventListener("error", () => { + profilePic.src = './src/images/default-avatar.png'; + }); + + // Username + const usernameEl = dialog.querySelector('.username') as HTMLElement; + usernameEl.textContent = profile.username; + + // Online status + const onlineStatus = dialog.querySelector('.online-status') as HTMLElement; + if (profile.online) { + onlineStatus.innerHTML = ' Online'; + onlineStatus.classList.add("online-status", "online"); + } else { + onlineStatus.innerHTML = ` Last seen ${formatTime(profile.last_seen)}`; + onlineStatus.classList.add("online-status", "offline"); + } + + // Bio + const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement; + const bioEdit = dialog.querySelector('#bio-edit-field') as any; + + if (profile.bio) { + bioDisplay.textContent = profile.bio; + } else { + bioDisplay.textContent = isOwnProfile ? 'No bio yet. Click "Edit Bio" to add one!' : 'No bio available.'; + } + + if (bioEdit) { + bioEdit.value = profile.bio || ''; + } + + // Stats + const memberSince = dialog.querySelector('.member-since') as HTMLElement; + const lastSeen = dialog.querySelector('.last-seen') as HTMLElement; + + memberSince.textContent = formatTime(profile.created_at); + lastSeen.textContent = formatTime(profile.last_seen); +} + +/** + * Starts editing the bio + * @private + */ +function startEditBio(): void { + if (!dialog) return; + + const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement; + const bioEdit = dialog.querySelector('#bio-edit-field') as any; + const bioActions = dialog.querySelector('.bio-actions') as HTMLElement; + const editBioBtn = dialog.querySelector('#edit-bio-btn') as HTMLElement; + + bioDisplay.style.display = 'none'; + if (bioEdit) bioEdit.style.display = 'block'; + bioActions.style.display = 'flex'; + editBioBtn.style.display = 'none'; + + if (bioEdit) { + bioEdit.focus(); + bioEdit.setSelectionRange(bioEdit.value.length, bioEdit.value.length); + } +} + +/** + * Saves the bio + * @private + */ +export async function saveBio(): Promise { + if (!dialog || !currentProfile) return; + + const bioEdit = dialog.querySelector('#bio-edit-field') as any; + const newBio = bioEdit?.value?.trim() || ''; + + try { + const response = await fetch(`${API_BASE_URL}/user/bio`, { + method: 'PUT', + headers: { + ...getAuthHeaders(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ bio: newBio }) + }); + + if (!response.ok) { + throw new Error('Failed to update bio'); + } + + const result = await response.json(); + currentProfile.bio = result.bio; + populateDialog(currentProfile); + cancelEditBio(); + showSuccess('Bio updated successfully'); + + } catch (error) { + showError('Failed to update bio'); + console.error('Error updating bio:', error); + } +} + +/** + * Cancels bio editing + * @private + */ +function cancelEditBio(): void { + if (!dialog) return; + + const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement; + const bioEdit = dialog.querySelector('#bio-edit-field') as any; + const bioActions = dialog.querySelector('.bio-actions') as HTMLElement; + const editBioBtn = dialog.querySelector('#edit-bio-btn') as HTMLElement; + + bioDisplay.style.display = 'block'; + if (bioEdit) bioEdit.style.display = 'none'; + bioActions.style.display = 'none'; + editBioBtn.style.display = isOwnProfile ? 'block' : 'none'; + + // Reset bio edit to current value + if (bioEdit) { + bioEdit.value = currentProfile?.bio || ''; + } +} + +/** + * Hides the dialog + */ +export function hide(): void { + if (dialog) { + (dialog as any).open = false; + } + currentProfile = null; + isOwnProfile = false; +} + +init(); \ No newline at end of file