From a2a3081e42dccf9c915509d375f693ecb7d9cb63 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 23 Aug 2025 15:37:48 +0300 Subject: [PATCH] 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