mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Convert components to static HTML
This commit is contained in:
+84
-1
@@ -194,7 +194,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<mdui-dialog id="profile-dialog" close-on-overlay-click close-on-esc>
|
<mdui-dialog id="profile-dialog" close-on-overlay-click close-on-esc>
|
||||||
<div class="profile-dialog-content">
|
<div class="content">
|
||||||
<div class="header-top">
|
<div class="header-top">
|
||||||
<div class="profile-picture-container">
|
<div class="profile-picture-container">
|
||||||
<img id="profile-picture" src="./src/images/default-avatar.png" alt="Ваше фото" />
|
<img id="profile-picture" src="./src/images/default-avatar.png" alt="Ваше фото" />
|
||||||
@@ -320,6 +320,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</mdui-dialog>
|
</mdui-dialog>
|
||||||
|
|
||||||
|
<div id="message-context-menu">
|
||||||
|
<div class="context-menu-item" data-action="reply">
|
||||||
|
<span class="material-symbols">reply</span>
|
||||||
|
Reply
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="edit">
|
||||||
|
<span class="material-symbols">edit</span>
|
||||||
|
Edit
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="delete">
|
||||||
|
<span class="material-symbols">delete</span>
|
||||||
|
Delete
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mdui-dialog id="edit-message-dialog" close-on-overlay-click close-on-esc>
|
||||||
|
<div class="dialog-content">
|
||||||
|
<h3>Edit Message</h3>
|
||||||
|
<mdui-text-field
|
||||||
|
id="edit-message-input"
|
||||||
|
label="Edit Message"
|
||||||
|
variant="outlined"
|
||||||
|
multiline
|
||||||
|
rows="4"
|
||||||
|
placeholder="Edit your message..."
|
||||||
|
maxlength="1000">
|
||||||
|
</mdui-text-field>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<mdui-button id="edit-cancel" variant="outlined">Cancel</mdui-button>
|
||||||
|
<mdui-button id="edit-save">Save</mdui-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mdui-dialog>
|
||||||
|
|
||||||
|
<mdui-dialog id="reply-message-dialog" close-on-overlay-click close-on-esc>
|
||||||
|
<div class="dialog-content">
|
||||||
|
<h3>Reply to Message</h3>
|
||||||
|
<div class="reply-preview" id="reply-preview"></div>
|
||||||
|
<mdui-text-field
|
||||||
|
id="reply-message-input"
|
||||||
|
label="Reply"
|
||||||
|
variant="outlined"
|
||||||
|
multiline
|
||||||
|
rows="4"
|
||||||
|
placeholder="Type your reply..."
|
||||||
|
maxlength="1000">
|
||||||
|
</mdui-text-field>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<mdui-button id="reply-cancel" variant="outlined">Cancel</mdui-button>
|
||||||
|
<mdui-button id="reply-send">Send Reply</mdui-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mdui-dialog>
|
||||||
|
|
||||||
|
<mdui-dialog id="user-profile-dialog" close-on-overlay-click close-on-esc>
|
||||||
|
<div class="content">
|
||||||
|
<div class="profile-picture-section">
|
||||||
|
<img class="profile-picture" src="" alt="Profile Picture">
|
||||||
|
</div>
|
||||||
|
<div class="profile-info">
|
||||||
|
<div class="username-section">
|
||||||
|
<h4 class="username"></h4>
|
||||||
|
<div class="online-status"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bio-section">
|
||||||
|
<label>Bio:</label>
|
||||||
|
<div class="bio-display"></div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Member since:</span>
|
||||||
|
<span class="stat-value member-since"></span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Last seen:</span>
|
||||||
|
<span class="stat-value last-seen"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mdui-dialog>
|
||||||
|
|
||||||
<script src="src/main.ts" type="module"></script>
|
<script src="src/main.ts" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -10,8 +10,8 @@ import { API_BASE_URL } from "./config";
|
|||||||
import { websocket } from "./websocket";
|
import { websocket } from "./websocket";
|
||||||
import type { Message, Messages, WebSocketMessage } from "./types";
|
import type { Message, Messages, WebSocketMessage } from "./types";
|
||||||
import { formatTime } from "./utils/utils";
|
import { formatTime } from "./utils/utils";
|
||||||
import { messageContextMenu } from "./message-context-menu";
|
import { show as showContextMenu } from "./message-context-menu";
|
||||||
import { userProfileDialog } from "./user-profile-dialog";
|
import { show as showUserProfileDialog } from "./user-profile-dialog";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a new message to the chat interface
|
* 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
|
// Add click handler to profile picture
|
||||||
profileImg.style.cursor = 'pointer';
|
profileImg.style.cursor = 'pointer';
|
||||||
profileImg.addEventListener('click', () => {
|
profileImg.addEventListener('click', () => {
|
||||||
userProfileDialog.show(message.username);
|
showUserProfileDialog(message.username);
|
||||||
});
|
});
|
||||||
|
|
||||||
profilePicDiv.appendChild(profileImg);
|
profilePicDiv.appendChild(profileImg);
|
||||||
@@ -65,7 +65,7 @@ export function addMessage(message: Message, isAuthor: boolean): void {
|
|||||||
// Add click handler to username
|
// Add click handler to username
|
||||||
usernameDiv.style.cursor = 'pointer';
|
usernameDiv.style.cursor = 'pointer';
|
||||||
usernameDiv.addEventListener('click', () => {
|
usernameDiv.addEventListener('click', () => {
|
||||||
userProfileDialog.show(message.username);
|
showUserProfileDialog(message.username);
|
||||||
});
|
});
|
||||||
|
|
||||||
messageInner.appendChild(usernameDiv);
|
messageInner.appendChild(usernameDiv);
|
||||||
@@ -111,7 +111,7 @@ export function addMessage(message: Message, isAuthor: boolean): void {
|
|||||||
// Add right-click context menu
|
// Add right-click context menu
|
||||||
messageDiv.addEventListener('contextmenu', (e) => {
|
messageDiv.addEventListener('contextmenu', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
messageContextMenu.show(message, e.clientX, e.clientY);
|
showContextMenu(message, e.clientX, e.clientY);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Прокрутка к новому сообщению
|
// Прокрутка к новому сообщению
|
||||||
|
|||||||
+1
-124
@@ -320,7 +320,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Message Context Menu
|
// Message Context Menu
|
||||||
.message-context-menu {
|
#message-context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background-color: $color-dark-surface-container;
|
background-color: $color-dark-surface-container;
|
||||||
border-radius: 8px;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
@use "common/colors" as *;
|
@use "common/colors" as *;
|
||||||
@use "common/material" as *;
|
@use "common/material" as *;
|
||||||
|
|
||||||
#profile-dialog {
|
#profile-dialog .content {
|
||||||
.profile-dialog-content {
|
display: flex;
|
||||||
display: flex;
|
flex-direction: column;
|
||||||
flex-direction: column;
|
gap: 24px;
|
||||||
gap: 24px;
|
min-width: 400px;
|
||||||
min-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-top {
|
.header-top {
|
||||||
display: flex;
|
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 Styles
|
||||||
#cropper-dialog {
|
#cropper-dialog {
|
||||||
.cropper-dialog-content {
|
.cropper-dialog-content {
|
||||||
|
|||||||
@@ -4,10 +4,6 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mt-3 {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
.alert {
|
||||||
padding: 0.8rem 1rem;
|
padding: 0.8rem 1rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|||||||
@@ -10,428 +10,339 @@ import { websocket } from "./websocket";
|
|||||||
import type { Message, WebSocketMessage } from "./types";
|
import type { Message, WebSocketMessage } from "./types";
|
||||||
import { showSuccess, showError } from "./utils/notification";
|
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
|
* Binds event listeners
|
||||||
* @class MessageContextMenu
|
* @private
|
||||||
*/
|
*/
|
||||||
class MessageContextMenu {
|
function bindEvents(): void {
|
||||||
private menu: HTMLElement | null = null;
|
// Context menu events
|
||||||
private editDialog: HTMLElement | null = null;
|
menu?.addEventListener('click', (e) => {
|
||||||
private replyDialog: HTMLElement | null = null;
|
const target = e.target as HTMLElement;
|
||||||
private currentMessage: Message | null = null;
|
const action = target.closest('.context-menu-item')?.getAttribute('data-action');
|
||||||
|
|
||||||
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 = `
|
|
||||||
<div class="context-menu-item" data-action="reply">
|
|
||||||
<span class="material-symbols">reply</span>
|
|
||||||
Reply
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" data-action="edit">
|
|
||||||
<span class="material-symbols">edit</span>
|
|
||||||
Edit
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" data-action="delete">
|
|
||||||
<span class="material-symbols">delete</span>
|
|
||||||
Delete
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
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 = `
|
|
||||||
<div class="dialog-content">
|
|
||||||
<h3>Edit Message</h3>
|
|
||||||
<mdui-text-field
|
|
||||||
id="edit-message-input"
|
|
||||||
label="Edit Message"
|
|
||||||
variant="outlined"
|
|
||||||
multiline
|
|
||||||
rows="4"
|
|
||||||
placeholder="Edit your message..."
|
|
||||||
maxlength="1000">
|
|
||||||
</mdui-text-field>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<mdui-button id="edit-cancel" variant="outlined">Cancel</mdui-button>
|
|
||||||
<mdui-button id="edit-save">Save</mdui-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
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 = `
|
|
||||||
<div class="dialog-content">
|
|
||||||
<h3>Reply to Message</h3>
|
|
||||||
<div class="reply-preview" id="reply-preview"></div>
|
|
||||||
<mdui-text-field
|
|
||||||
id="reply-message-input"
|
|
||||||
label="Reply"
|
|
||||||
variant="outlined"
|
|
||||||
multiline
|
|
||||||
rows="4"
|
|
||||||
placeholder="Type your reply..."
|
|
||||||
maxlength="1000">
|
|
||||||
</mdui-text-field>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<mdui-button id="reply-cancel" variant="outlined">Cancel</mdui-button>
|
|
||||||
<mdui-button id="reply-send">Send Reply</mdui-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
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
|
if (action && currentMessage) {
|
||||||
const editItem = this.menu.querySelector('[data-action="edit"]') as HTMLElement;
|
handleAction(action, currentMessage);
|
||||||
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
|
// Close menu when clicking outside
|
||||||
const viewportWidth = window.innerWidth;
|
document.addEventListener('click', (e) => {
|
||||||
const viewportHeight = window.innerHeight;
|
if (!menu?.contains(e.target as Node)) {
|
||||||
|
hide();
|
||||||
// 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
|
// Edit dialog events
|
||||||
if (y + menuHeight > viewportHeight) {
|
const editCancelBtn = editDialog?.querySelector('#edit-cancel');
|
||||||
adjustedY = y - menuHeight;
|
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);
|
* Shows the context menu at the specified position
|
||||||
adjustedY = Math.max(0, adjustedY);
|
* @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`;
|
currentMessage = message;
|
||||||
this.menu.style.top = `${adjustedY}px`;
|
|
||||||
this.menu.style.display = 'block';
|
// 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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Calculate menu position
|
||||||
* Hides the context menu
|
const viewportWidth = window.innerWidth;
|
||||||
*/
|
const viewportHeight = window.innerHeight;
|
||||||
public hide(): void {
|
|
||||||
if (this.menu) {
|
// Default menu size estimates
|
||||||
this.menu.style.display = 'none';
|
const menuWidth = 150;
|
||||||
}
|
const menuHeight = 120;
|
||||||
this.currentMessage = null;
|
|
||||||
|
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
|
||||||
* Handles context menu actions
|
if (y + menuHeight > viewportHeight) {
|
||||||
* @param {string} action - The action to perform
|
adjustedY = y - menuHeight;
|
||||||
* @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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Ensure menu doesn't go off the left or top edges
|
||||||
* Shows the edit dialog
|
adjustedX = Math.max(0, adjustedX);
|
||||||
* @param {Message} message - The message to edit
|
adjustedY = Math.max(0, adjustedY);
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private showEditDialog(message: Message): void {
|
|
||||||
if (!this.editDialog) return;
|
|
||||||
|
|
||||||
const textField = this.editDialog.querySelector('#edit-message-input') as any;
|
menu.style.left = `${adjustedX}px`;
|
||||||
if (textField) {
|
menu.style.top = `${adjustedY}px`;
|
||||||
textField.value = message.content;
|
menu.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentMessage = message;
|
/**
|
||||||
(this.editDialog as any).open = true;
|
* Hides the context menu
|
||||||
|
*/
|
||||||
// Focus the text field
|
export function hide(): void {
|
||||||
setTimeout(() => {
|
if (menu) {
|
||||||
textField?.focus();
|
menu.style.display = 'none';
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
|
currentMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hides the edit dialog
|
* Handles context menu actions
|
||||||
* @private
|
* @param {string} action - The action to perform
|
||||||
*/
|
* @param {Message} message - The message to act on
|
||||||
private hideEditDialog(): void {
|
* @private
|
||||||
if (this.editDialog) {
|
*/
|
||||||
(this.editDialog as any).open = false;
|
function handleAction(action: string, message: Message): void {
|
||||||
}
|
hide();
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
switch (action) {
|
||||||
* Saves the edited message
|
case 'edit':
|
||||||
* @private
|
showEditDialog(message);
|
||||||
*/
|
break;
|
||||||
private saveEdit(): void {
|
case 'delete':
|
||||||
if (!this.currentMessage || !this.editDialog) return;
|
deleteMessage(message);
|
||||||
|
break;
|
||||||
const textField = this.editDialog.querySelector('#edit-message-input') as any;
|
case 'reply':
|
||||||
const newContent = textField?.value?.trim() || '';
|
showReplyDialog(message);
|
||||||
|
break;
|
||||||
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 = `
|
|
||||||
<div class="reply-preview-content">
|
|
||||||
<strong>${message.username}</strong>: ${message.content}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
* 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 = `
|
||||||
|
<div class="reply-preview-content">
|
||||||
|
<strong>${message.username}</strong>: ${message.content}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
+190
-255
@@ -11,272 +11,207 @@ import type { UserProfile } from "./types";
|
|||||||
import { showError, showSuccess } from "./utils/notification";
|
import { showError, showSuccess } from "./utils/notification";
|
||||||
import { formatTime } from "./utils/utils";
|
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
|
* Binds event listeners
|
||||||
* @class UserProfileDialog
|
* @private
|
||||||
*/
|
*/
|
||||||
class UserProfileDialog {
|
function bindEvents(): void {
|
||||||
private dialog: HTMLElement | null = null;
|
// Edit bio events
|
||||||
private currentProfile: UserProfile | null = null;
|
const editBioBtn = dialog?.querySelector('#edit-bio-btn');
|
||||||
private isOwnProfile: boolean = false;
|
const saveBioBtn = dialog?.querySelector('#save-bio-btn');
|
||||||
|
const cancelBioBtn = dialog?.querySelector('#cancel-bio-btn');
|
||||||
|
|
||||||
constructor() {
|
editBioBtn?.addEventListener('click', () => startEditBio());
|
||||||
this.createDialog();
|
saveBioBtn?.addEventListener('click', () => saveBio());
|
||||||
this.bindEvents();
|
cancelBioBtn?.addEventListener('click', () => cancelEditBio());
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Keyboard shortcuts
|
||||||
* Creates the profile dialog using MDUI components
|
document.addEventListener('keydown', (e) => {
|
||||||
* @private
|
if (e.key === 'Escape' && dialog?.getAttribute('open') !== null) {
|
||||||
*/
|
hide();
|
||||||
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 = `
|
|
||||||
<div class="profile-dialog-content">
|
|
||||||
<div class="profile-picture-section">
|
|
||||||
<img class="profile-picture" src="" alt="Profile Picture">
|
|
||||||
</div>
|
|
||||||
<div class="profile-info">
|
|
||||||
<div class="username-section">
|
|
||||||
<h4 class="username"></h4>
|
|
||||||
<div class="online-status"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bio-section">
|
|
||||||
<label>Bio:</label>
|
|
||||||
<div class="bio-display"></div>
|
|
||||||
<mdui-text-field
|
|
||||||
id="bio-edit-field"
|
|
||||||
label="Bio"
|
|
||||||
variant="outlined"
|
|
||||||
multiline
|
|
||||||
rows="3"
|
|
||||||
placeholder="Write something about yourself..."
|
|
||||||
maxlength="500"
|
|
||||||
style="display: none;">
|
|
||||||
</mdui-text-field>
|
|
||||||
<div class="bio-actions" style="display: none;">
|
|
||||||
<mdui-button id="save-bio-btn">Save</mdui-button>
|
|
||||||
<mdui-button id="cancel-bio-btn" variant="outlined">Cancel</mdui-button>
|
|
||||||
</div>
|
|
||||||
<mdui-button id="edit-bio-btn" variant="outlined" style="display: none;">Edit Bio</mdui-button>
|
|
||||||
</div>
|
|
||||||
<div class="profile-stats">
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">Member since:</span>
|
|
||||||
<span class="stat-value member-since"></span>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">Last seen:</span>
|
|
||||||
<span class="stat-value last-seen"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(this.dialog);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Binds event listeners
|
* Shows the profile dialog for a specific user
|
||||||
* @private
|
* @param {string} username - Username to show profile for
|
||||||
*/
|
*/
|
||||||
private bindEvents(): void {
|
export async function show(username: string): Promise<void> {
|
||||||
// Edit bio events
|
if (!dialog) return;
|
||||||
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());
|
try {
|
||||||
saveBioBtn?.addEventListener('click', () => this.saveBio());
|
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
|
||||||
cancelBioBtn?.addEventListener('click', () => this.cancelEditBio());
|
headers: getAuthHeaders()
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && this.dialog?.getAttribute('open') !== null) {
|
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
if (!response.ok) {
|
||||||
* Shows the profile dialog for a specific user
|
throw new Error('Failed to load user profile');
|
||||||
* @param {string} username - Username to show profile for
|
|
||||||
*/
|
|
||||||
public async show(username: string): Promise<void> {
|
|
||||||
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 = '<span class="online-indicator"></span> Online';
|
|
||||||
onlineStatus.className = 'online-status online';
|
|
||||||
} else {
|
|
||||||
onlineStatus.innerHTML = `<span class="offline-indicator"></span> Last seen ${formatTime(profile.last_seen)}`;
|
|
||||||
onlineStatus.className = 'online-status offline';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bio
|
const profile: UserProfile = await response.json();
|
||||||
const bioDisplay = this.dialog.querySelector('.bio-display') as HTMLElement;
|
currentProfile = profile;
|
||||||
const bioEdit = this.dialog.querySelector('#bio-edit-field') as any;
|
isOwnProfile = profile.username === currentUser?.username;
|
||||||
|
populateDialog(profile);
|
||||||
if (profile.bio) {
|
(dialog as any).open = true;
|
||||||
bioDisplay.textContent = profile.bio;
|
|
||||||
} else {
|
|
||||||
bioDisplay.textContent = this.isOwnProfile ? 'No bio yet. Click "Edit Bio" to add one!' : 'No bio available.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bioEdit) {
|
} catch (error) {
|
||||||
bioEdit.value = profile.bio || '';
|
showError('Failed to load user profile');
|
||||||
}
|
console.error('Error loading user profile:', error);
|
||||||
|
|
||||||
// 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<void> {
|
|
||||||
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();
|
* 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 = '<span class="online-indicator"></span> Online';
|
||||||
|
onlineStatus.classList.add("online-status", "online");
|
||||||
|
} else {
|
||||||
|
onlineStatus.innerHTML = `<span class="offline-indicator"></span> 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<void> {
|
||||||
|
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();
|
||||||
Reference in New Issue
Block a user