Convert components to static HTML

This commit is contained in:
2025-08-23 15:37:48 +03:00
Unverified
parent 1eb4f8392a
commit a2a3081e42
7 changed files with 721 additions and 798 deletions
+84 -1
View File
@@ -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>
+5 -5
View File
@@ -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
View File
@@ -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;
}
}
}
}
}
+124 -3
View File
@@ -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
View File
@@ -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;
+93 -182
View File
@@ -10,163 +10,76 @@ 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
* @class MessageContextMenu
*/
class MessageContextMenu {
private menu: HTMLElement | null = null;
private editDialog: HTMLElement | null = null;
private replyDialog: HTMLElement | null = null;
private currentMessage: Message | null = null;
constructor() {
this.createMenu();
this.createDialogs();
this.bindEvents();
}
/**
* Creates the context menu element
* @private
*/
private createMenu(): void {
this.menu = document.createElement('div');
this.menu.className = 'message-context-menu';
this.menu.innerHTML = `
<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 * Binds event listeners
* @private * @private
*/ */
private bindEvents(): void { function bindEvents(): void {
// Context menu events // Context menu events
this.menu?.addEventListener('click', (e) => { menu?.addEventListener('click', (e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
const action = target.closest('.context-menu-item')?.getAttribute('data-action'); const action = target.closest('.context-menu-item')?.getAttribute('data-action');
if (action && this.currentMessage) { if (action && currentMessage) {
this.handleAction(action, this.currentMessage); handleAction(action, currentMessage);
} }
}); });
// Close menu when clicking outside // Close menu when clicking outside
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!this.menu?.contains(e.target as Node)) { if (!menu?.contains(e.target as Node)) {
this.hide(); hide();
} }
}); });
// Edit dialog events // Edit dialog events
const editCancelBtn = this.editDialog?.querySelector('#edit-cancel'); const editCancelBtn = editDialog?.querySelector('#edit-cancel');
const editSaveBtn = this.editDialog?.querySelector('#edit-save'); const editSaveBtn = editDialog?.querySelector('#edit-save');
editCancelBtn?.addEventListener('click', () => this.hideEditDialog()); editCancelBtn?.addEventListener('click', () => hideEditDialog());
editSaveBtn?.addEventListener('click', () => this.saveEdit()); editSaveBtn?.addEventListener('click', () => saveEdit());
// Reply dialog events // Reply dialog events
const replyCancelBtn = this.replyDialog?.querySelector('#reply-cancel'); const replyCancelBtn = replyDialog?.querySelector('#reply-cancel');
const replySendBtn = this.replyDialog?.querySelector('#reply-send'); const replySendBtn = replyDialog?.querySelector('#reply-send');
replyCancelBtn?.addEventListener('click', () => this.hideReplyDialog()); replyCancelBtn?.addEventListener('click', () => hideReplyDialog());
replySendBtn?.addEventListener('click', () => this.sendReply()); replySendBtn?.addEventListener('click', () => sendReply());
// Keyboard shortcuts // Keyboard shortcuts
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
this.hide(); hide();
this.hideEditDialog(); hideEditDialog();
this.hideReplyDialog(); hideReplyDialog();
} }
}); });
} }
/** /**
* Shows the context menu at the specified position * Shows the context menu at the specified position
* @param {Message} message - The message to show menu for * @param {Message} message - The message to show menu for
* @param {number} x - X coordinate * @param {number} x - X coordinate
* @param {number} y - Y coordinate * @param {number} y - Y coordinate
*/ */
public show(message: Message, x: number, y: number): void { export function show(message: Message, x: number, y: number): void {
if (!this.menu) return; if (!menu) return;
this.currentMessage = message; currentMessage = message;
// Only show edit and delete for own messages // Only show edit and delete for own messages
const editItem = this.menu.querySelector('[data-action="edit"]') as HTMLElement; const editItem = menu.querySelector('[data-action="edit"]') as HTMLElement;
const deleteItem = this.menu.querySelector('[data-action="delete"]') as HTMLElement; const deleteItem = menu.querySelector('[data-action="delete"]') as HTMLElement;
if (message.username === currentUser?.username) { if (message.username === currentUser?.username) {
editItem.style.display = 'flex'; editItem.style.display = 'flex';
@@ -201,83 +114,83 @@ class MessageContextMenu {
adjustedX = Math.max(0, adjustedX); adjustedX = Math.max(0, adjustedX);
adjustedY = Math.max(0, adjustedY); adjustedY = Math.max(0, adjustedY);
this.menu.style.left = `${adjustedX}px`; menu.style.left = `${adjustedX}px`;
this.menu.style.top = `${adjustedY}px`; menu.style.top = `${adjustedY}px`;
this.menu.style.display = 'block'; menu.style.display = 'block';
} }
/** /**
* Hides the context menu * Hides the context menu
*/ */
public hide(): void { export function hide(): void {
if (this.menu) { if (menu) {
this.menu.style.display = 'none'; menu.style.display = 'none';
}
this.currentMessage = null;
} }
currentMessage = null;
}
/** /**
* Handles context menu actions * Handles context menu actions
* @param {string} action - The action to perform * @param {string} action - The action to perform
* @param {Message} message - The message to act on * @param {Message} message - The message to act on
* @private * @private
*/ */
private handleAction(action: string, message: Message): void { function handleAction(action: string, message: Message): void {
this.hide(); hide();
switch (action) { switch (action) {
case 'edit': case 'edit':
this.showEditDialog(message); showEditDialog(message);
break; break;
case 'delete': case 'delete':
this.deleteMessage(message); deleteMessage(message);
break; break;
case 'reply': case 'reply':
this.showReplyDialog(message); showReplyDialog(message);
break; break;
} }
} }
/** /**
* Shows the edit dialog * Shows the edit dialog
* @param {Message} message - The message to edit * @param {Message} message - The message to edit
* @private * @private
*/ */
private showEditDialog(message: Message): void { function showEditDialog(message: Message): void {
if (!this.editDialog) return; if (!editDialog) return;
const textField = this.editDialog.querySelector('#edit-message-input') as any; const textField = editDialog.querySelector('#edit-message-input') as any;
if (textField) { if (textField) {
textField.value = message.content; textField.value = message.content;
} }
this.currentMessage = message; currentMessage = message;
(this.editDialog as any).open = true; (editDialog as any).open = true;
// Focus the text field // Focus the text field
setTimeout(() => { setTimeout(() => {
textField?.focus(); textField?.focus();
}, 100); }, 100);
} }
/** /**
* Hides the edit dialog * Hides the edit dialog
* @private * @private
*/ */
private hideEditDialog(): void { function hideEditDialog(): void {
if (this.editDialog) { if (editDialog) {
(this.editDialog as any).open = false; (editDialog as any).open = false;
}
} }
}
/** /**
* Saves the edited message * Saves the edited message
* @private * @private
*/ */
private saveEdit(): void { function saveEdit(): void {
if (!this.currentMessage || !this.editDialog) return; if (!currentMessage || !editDialog) return;
const textField = this.editDialog.querySelector('#edit-message-input') as any; const textField = editDialog.querySelector('#edit-message-input') as any;
const newContent = textField?.value?.trim() || ''; const newContent = textField?.value?.trim() || '';
if (!newContent) { if (!newContent) {
@@ -288,7 +201,7 @@ class MessageContextMenu {
const payload: WebSocketMessage = { const payload: WebSocketMessage = {
type: "editMessage", type: "editMessage",
data: { data: {
message_id: this.currentMessage.id, message_id: currentMessage.id,
content: newContent content: newContent
}, },
credentials: { credentials: {
@@ -306,22 +219,22 @@ class MessageContextMenu {
showError(response.error.detail); showError(response.error.detail);
} else { } else {
showSuccess('Message edited successfully'); showSuccess('Message edited successfully');
this.hideEditDialog(); hideEditDialog();
} }
}; };
websocket.addEventListener("message", callback); websocket.addEventListener("message", callback);
websocket.send(JSON.stringify(payload)); websocket.send(JSON.stringify(payload));
} }
/** /**
* Shows the reply dialog * Shows the reply dialog
* @param {Message} message - The message to reply to * @param {Message} message - The message to reply to
* @private * @private
*/ */
private showReplyDialog(message: Message): void { function showReplyDialog(message: Message): void {
if (!this.replyDialog) return; if (!replyDialog) return;
const preview = this.replyDialog.querySelector('#reply-preview') as HTMLElement; const preview = replyDialog.querySelector('#reply-preview') as HTMLElement;
if (preview) { if (preview) {
preview.innerHTML = ` preview.innerHTML = `
<div class="reply-preview-content"> <div class="reply-preview-content">
@@ -330,34 +243,34 @@ class MessageContextMenu {
`; `;
} }
this.currentMessage = message; currentMessage = message;
(this.replyDialog as any).open = true; (replyDialog as any).open = true;
// Focus the text field // Focus the text field
setTimeout(() => { setTimeout(() => {
const textField = this.replyDialog?.querySelector('#reply-message-input') as any; const textField = replyDialog?.querySelector('#reply-message-input') as any;
textField?.focus(); textField?.focus();
}, 100); }, 100);
} }
/** /**
* Hides the reply dialog * Hides the reply dialog
* @private * @private
*/ */
private hideReplyDialog(): void { function hideReplyDialog(): void {
if (this.replyDialog) { if (replyDialog) {
(this.replyDialog as any).open = false; (replyDialog as any).open = false;
}
} }
}
/** /**
* Sends the reply message * Sends the reply message
* @private * @private
*/ */
private sendReply(): void { function sendReply(): void {
if (!this.currentMessage || !this.replyDialog) return; if (!currentMessage || !replyDialog) return;
const textField = this.replyDialog.querySelector('#reply-message-input') as any; const textField = replyDialog.querySelector('#reply-message-input') as any;
const content = textField?.value?.trim() || ''; const content = textField?.value?.trim() || '';
if (!content) { if (!content) {
@@ -369,7 +282,7 @@ class MessageContextMenu {
type: "replyMessage", type: "replyMessage",
data: { data: {
content: content, content: content,
reply_to_id: this.currentMessage.id reply_to_id: currentMessage.id
}, },
credentials: { credentials: {
scheme: "Bearer", scheme: "Bearer",
@@ -386,7 +299,7 @@ class MessageContextMenu {
showError(response.error.detail); showError(response.error.detail);
} else { } else {
showSuccess('Reply sent successfully'); showSuccess('Reply sent successfully');
this.hideReplyDialog(); hideReplyDialog();
if (textField) { if (textField) {
textField.value = ''; textField.value = '';
} }
@@ -394,14 +307,14 @@ class MessageContextMenu {
}; };
websocket.addEventListener("message", callback); websocket.addEventListener("message", callback);
websocket.send(JSON.stringify(payload)); websocket.send(JSON.stringify(payload));
} }
/** /**
* Deletes a message * Deletes a message
* @param {Message} message - The message to delete * @param {Message} message - The message to delete
* @private * @private
*/ */
private deleteMessage(message: Message): void { function deleteMessage(message: Message): void {
if (!confirm('Are you sure you want to delete this message?')) { if (!confirm('Are you sure you want to delete this message?')) {
return; return;
} }
@@ -430,8 +343,6 @@ class MessageContextMenu {
}; };
websocket.addEventListener("message", callback); websocket.addEventListener("message", callback);
websocket.send(JSON.stringify(payload)); websocket.send(JSON.stringify(payload));
}
} }
// Export singleton instance init();
export const messageContextMenu = new MessageContextMenu();
+80 -145
View File
@@ -11,102 +11,43 @@ 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
* @class UserProfileDialog
*/
class UserProfileDialog {
private dialog: HTMLElement | null = null;
private currentProfile: UserProfile | null = null;
private isOwnProfile: boolean = false;
constructor() {
this.createDialog();
this.bindEvents();
}
/**
* Creates the profile dialog using MDUI components
* @private
*/
private createDialog(): void {
this.dialog = document.createElement('mdui-dialog');
this.dialog.id = 'user-profile-dialog';
this.dialog.setAttribute('close-on-overlay-click', '');
this.dialog.setAttribute('close-on-esc', '');
this.dialog.innerHTML = `
<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 * Binds event listeners
* @private * @private
*/ */
private bindEvents(): void { function bindEvents(): void {
// Edit bio events // Edit bio events
const editBioBtn = this.dialog?.querySelector('#edit-bio-btn'); const editBioBtn = dialog?.querySelector('#edit-bio-btn');
const saveBioBtn = this.dialog?.querySelector('#save-bio-btn'); const saveBioBtn = dialog?.querySelector('#save-bio-btn');
const cancelBioBtn = this.dialog?.querySelector('#cancel-bio-btn'); const cancelBioBtn = dialog?.querySelector('#cancel-bio-btn');
editBioBtn?.addEventListener('click', () => this.startEditBio()); editBioBtn?.addEventListener('click', () => startEditBio());
saveBioBtn?.addEventListener('click', () => this.saveBio()); saveBioBtn?.addEventListener('click', () => saveBio());
cancelBioBtn?.addEventListener('click', () => this.cancelEditBio()); cancelBioBtn?.addEventListener('click', () => cancelEditBio());
// Keyboard shortcuts // Keyboard shortcuts
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.dialog?.getAttribute('open') !== null) { if (e.key === 'Escape' && dialog?.getAttribute('open') !== null) {
this.hide(); hide();
} }
}); });
} }
/** /**
* Shows the profile dialog for a specific user * Shows the profile dialog for a specific user
* @param {string} username - Username to show profile for * @param {string} username - Username to show profile for
*/ */
public async show(username: string): Promise<void> { export async function show(username: string): Promise<void> {
if (!this.dialog) return; if (!dialog) return;
try { try {
const response = await fetch(`${API_BASE_URL}/user/${username}`, { const response = await fetch(`${API_BASE_URL}/user/${username}`, {
@@ -118,54 +59,54 @@ class UserProfileDialog {
} }
const profile: UserProfile = await response.json(); const profile: UserProfile = await response.json();
this.currentProfile = profile; currentProfile = profile;
this.isOwnProfile = profile.username === currentUser?.username; isOwnProfile = profile.username === currentUser?.username;
this.populateDialog(profile); populateDialog(profile);
(this.dialog as any).open = true; (dialog as any).open = true;
} catch (error) { } catch (error) {
showError('Failed to load user profile'); showError('Failed to load user profile');
console.error('Error loading user profile:', error); console.error('Error loading user profile:', error);
} }
} }
/** /**
* Populates the dialog with user data * Populates the dialog with user data
* @param {UserProfile} profile - User profile data * @param {UserProfile} profile - User profile data
* @private * @private
*/ */
private populateDialog(profile: UserProfile): void { function populateDialog(profile: UserProfile): void {
if (!this.dialog) return; if (!dialog) return;
// Profile picture // Profile picture
const profilePic = this.dialog.querySelector('.profile-picture') as HTMLImageElement; const profilePic = dialog.querySelector('.profile-picture') as HTMLImageElement;
profilePic.src = profile.profile_picture || './src/images/default-avatar.png'; profilePic.src = profile.profile_picture || './src/images/default-avatar.png';
profilePic.onerror = () => { profilePic.addEventListener("error", () => {
profilePic.src = './src/images/default-avatar.png'; profilePic.src = './src/images/default-avatar.png';
}; });
// Username // Username
const usernameEl = this.dialog.querySelector('.username') as HTMLElement; const usernameEl = dialog.querySelector('.username') as HTMLElement;
usernameEl.textContent = profile.username; usernameEl.textContent = profile.username;
// Online status // Online status
const onlineStatus = this.dialog.querySelector('.online-status') as HTMLElement; const onlineStatus = dialog.querySelector('.online-status') as HTMLElement;
if (profile.online) { if (profile.online) {
onlineStatus.innerHTML = '<span class="online-indicator"></span> Online'; onlineStatus.innerHTML = '<span class="online-indicator"></span> Online';
onlineStatus.className = 'online-status online'; onlineStatus.classList.add("online-status", "online");
} else { } else {
onlineStatus.innerHTML = `<span class="offline-indicator"></span> Last seen ${formatTime(profile.last_seen)}`; onlineStatus.innerHTML = `<span class="offline-indicator"></span> Last seen ${formatTime(profile.last_seen)}`;
onlineStatus.className = 'online-status offline'; onlineStatus.classList.add("online-status", "offline");
} }
// Bio // Bio
const bioDisplay = this.dialog.querySelector('.bio-display') as HTMLElement; const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; const bioEdit = dialog.querySelector('#bio-edit-field') as any;
if (profile.bio) { if (profile.bio) {
bioDisplay.textContent = profile.bio; bioDisplay.textContent = profile.bio;
} else { } else {
bioDisplay.textContent = this.isOwnProfile ? 'No bio yet. Click "Edit Bio" to add one!' : 'No bio available.'; bioDisplay.textContent = isOwnProfile ? 'No bio yet. Click "Edit Bio" to add one!' : 'No bio available.';
} }
if (bioEdit) { if (bioEdit) {
@@ -173,28 +114,24 @@ class UserProfileDialog {
} }
// Stats // Stats
const memberSince = this.dialog.querySelector('.member-since') as HTMLElement; const memberSince = dialog.querySelector('.member-since') as HTMLElement;
const lastSeen = this.dialog.querySelector('.last-seen') as HTMLElement; const lastSeen = dialog.querySelector('.last-seen') as HTMLElement;
memberSince.textContent = formatTime(profile.created_at); memberSince.textContent = formatTime(profile.created_at);
lastSeen.textContent = formatTime(profile.last_seen); 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 * Starts editing the bio
* @private * @private
*/ */
private startEditBio(): void { function startEditBio(): void {
if (!this.dialog) return; if (!dialog) return;
const bioDisplay = this.dialog.querySelector('.bio-display') as HTMLElement; const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; const bioEdit = dialog.querySelector('#bio-edit-field') as any;
const bioActions = this.dialog.querySelector('.bio-actions') as HTMLElement; const bioActions = dialog.querySelector('.bio-actions') as HTMLElement;
const editBioBtn = this.dialog.querySelector('#edit-bio-btn') as HTMLElement; const editBioBtn = dialog.querySelector('#edit-bio-btn') as HTMLElement;
bioDisplay.style.display = 'none'; bioDisplay.style.display = 'none';
if (bioEdit) bioEdit.style.display = 'block'; if (bioEdit) bioEdit.style.display = 'block';
@@ -205,16 +142,16 @@ class UserProfileDialog {
bioEdit.focus(); bioEdit.focus();
bioEdit.setSelectionRange(bioEdit.value.length, bioEdit.value.length); bioEdit.setSelectionRange(bioEdit.value.length, bioEdit.value.length);
} }
} }
/** /**
* Saves the bio * Saves the bio
* @private * @private
*/ */
private async saveBio(): Promise<void> { export async function saveBio(): Promise<void> {
if (!this.dialog || !this.currentProfile) return; if (!dialog || !currentProfile) return;
const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; const bioEdit = dialog.querySelector('#bio-edit-field') as any;
const newBio = bioEdit?.value?.trim() || ''; const newBio = bioEdit?.value?.trim() || '';
try { try {
@@ -232,51 +169,49 @@ class UserProfileDialog {
} }
const result = await response.json(); const result = await response.json();
this.currentProfile.bio = result.bio; currentProfile.bio = result.bio;
this.populateDialog(this.currentProfile); populateDialog(currentProfile);
this.cancelEditBio(); cancelEditBio();
showSuccess('Bio updated successfully'); showSuccess('Bio updated successfully');
} catch (error) { } catch (error) {
showError('Failed to update bio'); showError('Failed to update bio');
console.error('Error updating bio:', error); console.error('Error updating bio:', error);
} }
} }
/** /**
* Cancels bio editing * Cancels bio editing
* @private * @private
*/ */
private cancelEditBio(): void { function cancelEditBio(): void {
if (!this.dialog) return; if (!dialog) return;
const bioDisplay = this.dialog.querySelector('.bio-display') as HTMLElement; const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; const bioEdit = dialog.querySelector('#bio-edit-field') as any;
const bioActions = this.dialog.querySelector('.bio-actions') as HTMLElement; const bioActions = dialog.querySelector('.bio-actions') as HTMLElement;
const editBioBtn = this.dialog.querySelector('#edit-bio-btn') as HTMLElement; const editBioBtn = dialog.querySelector('#edit-bio-btn') as HTMLElement;
bioDisplay.style.display = 'block'; bioDisplay.style.display = 'block';
if (bioEdit) bioEdit.style.display = 'none'; if (bioEdit) bioEdit.style.display = 'none';
bioActions.style.display = 'none'; bioActions.style.display = 'none';
editBioBtn.style.display = this.isOwnProfile ? 'block' : 'none'; editBioBtn.style.display = isOwnProfile ? 'block' : 'none';
// Reset bio edit to current value // Reset bio edit to current value
if (bioEdit) { if (bioEdit) {
bioEdit.value = this.currentProfile?.bio || ''; bioEdit.value = 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(); * Hides the dialog
*/
export function hide(): void {
if (dialog) {
(dialog as any).open = false;
}
currentProfile = null;
isOwnProfile = false;
}
init();