| |
| |
| |
| |
| |
| 'use strict'; |
|
|
| |
| const API_BASE = '/admin/api'; |
| const TOKEN_KEY = 'rox_admin_token'; |
| const SESSION_TIMEOUT = 24 * 60 * 60 * 1000; |
|
|
| |
| const HTML_ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; |
|
|
| |
| const SUPERSCRIPTS = { |
| '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', |
| '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹', |
| '+': '⁺', '-': '⁻', '=': '⁼', '(': '⁽', ')': '⁾', |
| 'n': 'ⁿ', 'i': 'ⁱ', 'x': 'ˣ', 'y': 'ʸ', 'a': 'ᵃ', |
| 'b': 'ᵇ', 'c': 'ᶜ', 'd': 'ᵈ', 'e': 'ᵉ', 'f': 'ᶠ', |
| 'g': 'ᵍ', 'h': 'ʰ', 'j': 'ʲ', 'k': 'ᵏ', 'l': 'ˡ', |
| 'm': 'ᵐ', 'o': 'ᵒ', 'p': 'ᵖ', 'r': 'ʳ', 's': 'ˢ', |
| 't': 'ᵗ', 'u': 'ᵘ', 'v': 'ᵛ', 'w': 'ʷ', 'z': 'ᶻ' |
| }; |
|
|
| |
| const SUBSCRIPTS = { |
| '0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄', |
| '5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉', |
| '+': '₊', '-': '₋', '=': '₌', '(': '₍', ')': '₎', |
| 'a': 'ₐ', 'e': 'ₑ', 'h': 'ₕ', 'i': 'ᵢ', 'j': 'ⱼ', |
| 'k': 'ₖ', 'l': 'ₗ', 'm': 'ₘ', 'n': 'ₙ', 'o': 'ₒ', |
| 'p': 'ₚ', 'r': 'ᵣ', 's': 'ₛ', 't': 'ₜ', 'u': 'ᵤ', |
| 'v': 'ᵥ', 'x': 'ₓ' |
| }; |
|
|
| |
| const LANGUAGE_NAMES = { |
| 'js': 'javascript', 'ts': 'typescript', 'py': 'python', 'rb': 'ruby', |
| 'sh': 'bash', 'yml': 'yaml', 'md': 'markdown', 'cs': 'csharp', 'cpp': 'c++' |
| }; |
|
|
| |
| class AdminPanel { |
| constructor() { |
| this.token = null; |
| this.currentUser = null; |
| this.currentChat = null; |
| this.users = []; |
| this.chats = []; |
| this.stats = {}; |
| this.refreshInterval = null; |
| |
| this._init(); |
| } |
|
|
| _init() { |
| this._initElements(); |
| this._initEventListeners(); |
| this._initKeyboardShortcuts(); |
| this._checkAuth(); |
| } |
|
|
| _initElements() { |
| |
| this.loginOverlay = document.getElementById('loginOverlay'); |
| this.loginForm = document.getElementById('loginForm'); |
| this.passwordInput = document.getElementById('passwordInput'); |
| this.loginError = document.getElementById('loginError'); |
| this.loginAttempts = document.getElementById('loginAttempts'); |
| this.loginBtn = document.getElementById('loginBtn'); |
| |
| |
| this.app = document.getElementById('app'); |
| this.sidebar = document.getElementById('sidebar'); |
| this.sidebarOverlay = document.getElementById('sidebarOverlay'); |
| this.menuBtn = document.getElementById('menuBtn'); |
| this.mobileMenuBtn = document.getElementById('mobileMenuBtn'); |
| this.sidebarCloseBtn = document.getElementById('sidebarCloseBtn'); |
| |
| |
| this.totalUsers = document.getElementById('totalUsers'); |
| this.totalChats = document.getElementById('totalChats'); |
| this.todayQueries = document.getElementById('todayQueries'); |
| this.avgResponseTime = document.getElementById('avgResponseTime'); |
| this.totalMessages = document.getElementById('totalMessages'); |
| this.activeSessions = document.getElementById('activeSessions'); |
| this.modelUsageChart = document.getElementById('modelUsageChart'); |
| this.systemHealthChart = document.getElementById('systemHealthChart'); |
| |
| |
| this.userList = document.getElementById('userList'); |
| this.userSearch = document.getElementById('userSearch'); |
| this.selectedUserName = document.getElementById('selectedUserName'); |
| |
| |
| this.chatsPanel = document.getElementById('chatsPanel'); |
| this.chatsPanelCloseBtn = document.getElementById('chatsPanelCloseBtn'); |
| this.chatsPanelOverlay = document.getElementById('chatsPanelOverlay'); |
| this.chatsList = document.getElementById('chatsList'); |
| this.chatView = document.getElementById('chatView'); |
| this.chatHeader = document.getElementById('chatHeader'); |
| this.chatTitle = document.getElementById('chatTitle'); |
| this.chatMeta = document.getElementById('chatMeta'); |
| this.messagesArea = document.getElementById('messagesArea'); |
| |
| |
| this.refreshBtn = document.getElementById('refreshBtn'); |
| this.exportBtn = document.getElementById('exportBtn'); |
| this.exportDropdown = document.getElementById('exportDropdown'); |
| this.exportJsonBtn = document.getElementById('exportJsonBtn'); |
| this.exportPdfBtn = document.getElementById('exportPdfBtn'); |
| this.exportUsersPdfBtn = document.getElementById('exportUsersPdfBtn'); |
| this.exportChatJsonBtn = document.getElementById('exportChatJsonBtn'); |
| this.exportChatPdfBtn = document.getElementById('exportChatPdfBtn'); |
| this.clearBtn = document.getElementById('clearBtn'); |
| this.logoutBtn = document.getElementById('btnLogout'); |
| this.lastUpdated = document.getElementById('lastUpdated'); |
| this.lastUpdateTime = null; |
| |
| |
| this.tabBtns = document.querySelectorAll('.tab-btn'); |
| this.usersTab = document.getElementById('usersTab'); |
| this.statsTab = document.getElementById('statsTab'); |
| |
| |
| this.dialogOverlay = document.getElementById('dialogOverlay'); |
| this.dialogTitle = document.getElementById('dialogTitle'); |
| this.dialogMessage = document.getElementById('dialogMessage'); |
| this.dialogConfirm = document.getElementById('dialogConfirm'); |
| this.dialogCancel = document.getElementById('dialogCancel'); |
| |
| |
| this.helpOverlay = document.getElementById('helpOverlay'); |
| this.helpBtn = document.getElementById('helpBtn'); |
| this.helpClose = document.getElementById('helpClose'); |
| |
| |
| this.toastContainer = document.getElementById('toastContainer'); |
| |
| |
| this.connectionStatus = document.getElementById('connectionStatus'); |
| this.connectionDot = this.connectionStatus?.querySelector('.connection-dot'); |
| this.connectionText = this.connectionStatus?.querySelector('span'); |
| |
| |
| this.autoRefreshIndicator = document.getElementById('autoRefreshIndicator'); |
| |
| |
| this.searchResultsCount = document.getElementById('searchResultsCount'); |
| |
| |
| this.chatCountBadge = document.getElementById('chatCountBadge'); |
| this.chatCount = document.getElementById('chatCount'); |
| |
| |
| this.breadcrumb = document.getElementById('breadcrumb'); |
| |
| |
| this.performanceIndicators = document.getElementById('performanceIndicators'); |
| |
| |
| this.usersTrend = document.getElementById('usersTrend'); |
| this.chatsTrend = document.getElementById('chatsTrend'); |
| this.todayTrend = document.getElementById('todayTrend'); |
| |
| |
| this.responseTimeSparkline = document.getElementById('responseTimeSparkline'); |
| this.messagesSparkline = document.getElementById('messagesSparkline'); |
| this.activityIndicator = document.getElementById('activityIndicator'); |
| |
| |
| this.peakHour = document.getElementById('peakHour'); |
| this.peakHourLabel = document.getElementById('peakHourLabel'); |
| this.onlineUsers = document.getElementById('onlineUsers'); |
| this.avgChatsPerUser = document.getElementById('avgChatsPerUser'); |
| this.serverInfoChart = document.getElementById('serverInfoChart'); |
| this.hourlyUsageChart = document.getElementById('hourlyUsageChart'); |
| |
| |
| this.summaryTotalUsers = document.getElementById('summaryTotalUsers'); |
| this.summaryOnline = document.getElementById('summaryOnline'); |
| this.summaryToday = document.getElementById('summaryToday'); |
| |
| |
| this.yesterdayQueries = document.getElementById('yesterdayQueries'); |
| this.weekQueries = document.getElementById('weekQueries'); |
| this.monthQueries = document.getElementById('monthQueries'); |
| this.newUsersToday = document.getElementById('newUsersToday'); |
| this.newUsersWeek = document.getElementById('newUsersWeek'); |
| this.avgMessagesPerChat = document.getElementById('avgMessagesPerChat'); |
| this.dailyActivityChart = document.getElementById('dailyActivityChart'); |
| this.browserDistribution = document.getElementById('browserDistribution'); |
| this.osDistribution = document.getElementById('osDistribution'); |
| this.deviceDistribution = document.getElementById('deviceDistribution'); |
| this.languageDistribution = document.getElementById('languageDistribution'); |
| this.refererDistribution = document.getElementById('refererDistribution'); |
| this.countryDistribution = document.getElementById('countryDistribution'); |
| this.screenDistribution = document.getElementById('screenDistribution'); |
| |
| |
| this.realtimeOnline = document.getElementById('realtimeOnline'); |
| this.lastHourQueries = document.getElementById('lastHourQueries'); |
| this.returningUsers = document.getElementById('returningUsers'); |
| this.returningRate = document.getElementById('returningRate'); |
| this.totalPageViews = document.getElementById('totalPageViews'); |
| this.avgPageViews = document.getElementById('avgPageViews'); |
| this.avgSessionDurationEl = document.getElementById('avgSessionDuration'); |
| this.totalSessionsEl = document.getElementById('totalSessions'); |
| this.bounceRateEl = document.getElementById('bounceRate'); |
| this.newUsersMonthEl = document.getElementById('newUsersMonth'); |
| this.totalErrorsEl = document.getElementById('totalErrors'); |
| this.errorRateEl = document.getElementById('errorRate'); |
| this.recentErrorsEl = document.getElementById('recentErrors'); |
| this.totalTokensEl = document.getElementById('totalTokens'); |
| this.topUsersChart = document.getElementById('topUsersChart'); |
| |
| |
| this.avgEngagement = document.getElementById('avgEngagement'); |
| this.avgTimeOnSite = document.getElementById('avgTimeOnSite'); |
| this.mobilePercent = document.getElementById('mobilePercent'); |
| this.avgSessionsPerUser = document.getElementById('avgSessionsPerUser'); |
| this.weeklyHeatmap = document.getElementById('weeklyHeatmap'); |
| |
| |
| this.userDetailsPanel = document.getElementById('userDetailsPanel'); |
| this.userDetailsContent = document.getElementById('userDetailsContent'); |
| this.closeUserDetailsBtn = document.getElementById('closeUserDetails'); |
| } |
|
|
| _initEventListeners() { |
| |
| this.loginForm?.addEventListener('submit', (e) => this._handleLogin(e)); |
| |
| |
| this.logoutBtn?.addEventListener('click', () => this._logout()); |
| |
| |
| this.menuBtn?.addEventListener('click', () => this._toggleSidebar()); |
| this.mobileMenuBtn?.addEventListener('click', () => this._toggleSidebar()); |
| this.sidebarCloseBtn?.addEventListener('click', () => this._closeSidebar()); |
| this.sidebarOverlay?.addEventListener('click', () => this._closeSidebar()); |
| |
| |
| this.chatsPanelCloseBtn?.addEventListener('click', () => this._closeChatsPanel()); |
| this.chatsPanelOverlay?.addEventListener('click', () => this._closeChatsPanel()); |
| |
| |
| this.tabBtns.forEach(btn => { |
| btn.addEventListener('click', () => this._switchTab(btn.dataset.tab)); |
| }); |
| |
| |
| let searchTimeout; |
| this.userSearch?.addEventListener('input', (e) => { |
| clearTimeout(searchTimeout); |
| const query = e.target.value; |
| |
| |
| if (query) { |
| e.target.style.borderColor = 'var(--accent)'; |
| e.target.style.boxShadow = '0 0 0 3px rgba(62, 180, 137, 0.15)'; |
| } else { |
| e.target.style.borderColor = ''; |
| e.target.style.boxShadow = ''; |
| } |
| |
| searchTimeout = setTimeout(() => { |
| this._filterUsers(query); |
| }, 300); |
| }); |
| |
| |
| this.refreshBtn?.addEventListener('click', () => this._refreshData()); |
| this.exportBtn?.addEventListener('click', (e) => this._toggleExportDropdown(e)); |
| this.exportJsonBtn?.addEventListener('click', () => this._exportJson()); |
| this.exportPdfBtn?.addEventListener('click', () => this._exportStatsPdf()); |
| this.exportUsersPdfBtn?.addEventListener('click', () => this._exportUsersPdf()); |
| this.exportChatJsonBtn?.addEventListener('click', () => this._exportChatJson()); |
| this.exportChatPdfBtn?.addEventListener('click', () => this._exportChatPdf()); |
| this.clearBtn?.addEventListener('click', () => this._confirmClearLogs()); |
| |
| |
| this.helpBtn?.addEventListener('click', () => this._showHelp()); |
| this.helpClose?.addEventListener('click', () => this._hideHelp()); |
| |
| |
| this.closeUserDetailsBtn?.addEventListener('click', () => this._hideUserDetails()); |
| |
| |
| document.addEventListener('click', (e) => { |
| if (this.exportDropdown && !this.exportDropdown.contains(e.target)) { |
| this.exportDropdown.classList.remove('open'); |
| } |
| }); |
| |
| |
| this.dialogCancel?.addEventListener('click', () => this._hideDialog()); |
| this.dialogOverlay?.addEventListener('click', (e) => { |
| if (e.target === this.dialogOverlay) this._hideDialog(); |
| }); |
| |
| this.helpOverlay?.addEventListener('click', (e) => { |
| if (e.target === this.helpOverlay) this._hideHelp(); |
| }); |
| } |
|
|
| |
| _checkAuth() { |
| const token = localStorage.getItem(TOKEN_KEY); |
| if (token && this._isTokenValid(token)) { |
| this.token = token; |
| this._showApp(); |
| this._loadData(); |
| } else { |
| localStorage.removeItem(TOKEN_KEY); |
| this._showLogin(); |
| } |
| } |
|
|
| _isTokenValid(token) { |
| try { |
| const payload = JSON.parse(atob(token.split('.')[1])); |
| return payload.exp * 1000 > Date.now(); |
| } catch { |
| return false; |
| } |
| } |
|
|
| async _handleLogin(e) { |
| e.preventDefault(); |
| const password = this.passwordInput?.value?.trim(); |
| |
| if (!password) { |
| this._showLoginError('Please enter a password'); |
| return; |
| } |
| |
| this._setLoginLoading(true); |
| |
| try { |
| const response = await fetch(`${API_BASE}/login`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ password }) |
| }); |
| |
| const data = await response.json(); |
| |
| if (data.success && data.token) { |
| this.token = data.token; |
| localStorage.setItem(TOKEN_KEY, data.token); |
| this._showApp(); |
| this._loadData(); |
| this._showToast('Login successful', 'success'); |
| } else { |
| this._showLoginError(data.error || 'Invalid password'); |
| if (data.attemptsLeft !== undefined) { |
| this.loginAttempts.textContent = `${data.attemptsLeft} attempts remaining`; |
| } |
| if (data.lockoutTime) { |
| this._showLoginError(`Too many attempts. Try again in ${Math.ceil(data.lockoutTime / 60)} minutes`); |
| } |
| } |
| } catch (err) { |
| this._showLoginError('Connection error. Please try again.'); |
| } finally { |
| this._setLoginLoading(false); |
| } |
| } |
|
|
| _logout() { |
| localStorage.removeItem(TOKEN_KEY); |
| this.token = null; |
| if (this.refreshInterval) clearInterval(this.refreshInterval); |
| if (this.lastUpdatedInterval) clearInterval(this.lastUpdatedInterval); |
| this._showLogin(); |
| this._showToast('Logged out successfully', 'success'); |
| } |
|
|
| _showLogin() { |
| this.loginOverlay.style.display = 'flex'; |
| this.app.style.display = 'none'; |
| this.passwordInput.value = ''; |
| this.loginError.textContent = ''; |
| this.loginAttempts.textContent = ''; |
| } |
|
|
| _showApp() { |
| this.loginOverlay.style.display = 'none'; |
| this.app.style.display = 'flex'; |
| |
| |
| this._updateBreadcrumb(['Dashboard']); |
| |
| |
| this.refreshInterval = setInterval(() => this._loadData(), 5000); |
| |
| this.lastUpdatedInterval = setInterval(() => this._updateLastUpdatedDisplay(), 1000); |
| } |
|
|
| _showLoginError(msg) { |
| this.loginError.textContent = msg; |
| } |
|
|
| _setLoginLoading(loading) { |
| const btnText = this.loginBtn?.querySelector('.btn-text'); |
| const btnLoading = this.loginBtn?.querySelector('.btn-loading'); |
| if (loading) { |
| btnText.style.display = 'none'; |
| btnLoading.style.display = 'inline-flex'; |
| this.loginBtn.disabled = true; |
| } else { |
| btnText.style.display = 'inline'; |
| btnLoading.style.display = 'none'; |
| this.loginBtn.disabled = false; |
| } |
| } |
|
|
| |
| async _loadData() { |
| try { |
| |
| this._setLoadingState(true, 'loading'); |
| |
| const [statsRes, usersRes] = await Promise.all([ |
| this._apiGet('/stats'), |
| this._apiGet('/users') |
| ]); |
| |
| if (statsRes.success) { |
| this.stats = statsRes.data; |
| this._renderStats(); |
| } |
| |
| if (usersRes.success) { |
| |
| const newUsers = usersRes.data.users || []; |
| if (this._hasUsersChanged(newUsers)) { |
| this.users = newUsers; |
| this._renderUsers(); |
| } else { |
| |
| this._updateUserTimestamps(newUsers); |
| } |
| } |
| |
| |
| this.lastUpdateTime = Date.now(); |
| this._updateLastUpdatedDisplay(); |
| |
| |
| this._setLoadingState(false); |
| } catch (err) { |
| console.error('Failed to load data:', err); |
| this._setLoadingState(false); |
| if (err.message === 'Unauthorized') { |
| this._logout(); |
| } else { |
| this._showToast('Failed to load data', 'error'); |
| } |
| } |
| } |
| |
| _hasUsersChanged(newUsers) { |
| if (!this.users || this.users.length !== newUsers.length) return true; |
| |
| |
| for (let i = 0; i < newUsers.length; i++) { |
| const oldUser = this.users.find(u => u.ip === newUsers[i].ip); |
| const newUser = newUsers[i]; |
| if (!oldUser || oldUser.chatCount !== newUser.chatCount) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| _updateUserTimestamps(newUsers) { |
| |
| newUsers.forEach(newUser => { |
| const userCard = this.userList?.querySelector(`[data-ip="${this.escapeHtml(newUser.ip)}"]`); |
| if (userCard) { |
| const timeElement = userCard.querySelector('.user-time'); |
| const statusElement = userCard.querySelector('.user-status'); |
| |
| if (timeElement) { |
| timeElement.textContent = this._formatTimeAgo(newUser.lastActivity); |
| } |
| |
| if (statusElement) { |
| statusElement.classList.toggle('online', newUser.isOnline); |
| statusElement.classList.toggle('status-indicator', newUser.isOnline); |
| } |
| } |
| }); |
| |
| |
| this.users = newUsers; |
| } |
| |
| _setLoadingState(loading, context = 'data') { |
| const elements = [this.userList, this.modelUsageChart]; |
| elements.forEach(el => { |
| if (el) { |
| if (loading) { |
| el.classList.add('loading-pulse'); |
| |
| if (context === 'refresh') { |
| el.style.opacity = '0.7'; |
| } |
| } else { |
| el.classList.remove('loading-pulse'); |
| el.style.opacity = '1'; |
| } |
| } |
| }); |
| |
| |
| if (this.refreshBtn) { |
| if (loading) { |
| this.refreshBtn.disabled = true; |
| this.refreshBtn.classList.add('spinning', 'loading'); |
| this.refreshBtn.querySelector('span').textContent = 'Refreshing...'; |
| } else { |
| this.refreshBtn.disabled = false; |
| this.refreshBtn.classList.remove('spinning', 'loading'); |
| this.refreshBtn.querySelector('span').textContent = 'Refresh'; |
| } |
| } |
| } |
| |
| _updateLastUpdatedDisplay() { |
| if (!this.lastUpdated || !this.lastUpdateTime) return; |
| |
| const seconds = Math.floor((Date.now() - this.lastUpdateTime) / 1000); |
| const indicator = document.getElementById('autoRefreshIndicator'); |
| |
| if (seconds < 5) { |
| this.lastUpdated.textContent = 'Updated just now'; |
| this.lastUpdated.style.color = 'var(--success)'; |
| if (indicator) indicator.style.opacity = '1'; |
| } else if (seconds < 60) { |
| this.lastUpdated.textContent = `Updated ${seconds}s ago`; |
| this.lastUpdated.style.color = 'var(--text-secondary)'; |
| if (indicator) indicator.style.opacity = '0.7'; |
| } else { |
| const mins = Math.floor(seconds / 60); |
| this.lastUpdated.textContent = `Updated ${mins}m ago`; |
| this.lastUpdated.style.color = mins > 5 ? 'var(--warning)' : 'var(--text-tertiary)'; |
| if (indicator) indicator.style.opacity = mins > 5 ? '0.5' : '0.6'; |
| } |
| } |
|
|
| async _loadUserChats(userIp) { |
| try { |
| const res = await this._apiGet(`/users/${encodeURIComponent(userIp)}/chats`); |
| if (res.success) { |
| this.chats = res.data.chats || []; |
| this._renderChats(); |
| } |
| } catch (err) { |
| console.error('Failed to load chats:', err); |
| this._showToast('Failed to load chats', 'error'); |
| } |
| } |
|
|
| async _loadChatMessages(chatId) { |
| try { |
| const res = await this._apiGet(`/chats/${encodeURIComponent(chatId)}/messages`); |
| if (res.success) { |
| this._currentMessages = res.data.messages || []; |
| this._renderMessages(this._currentMessages, res.data.chat); |
| } |
| } catch (err) { |
| console.error('Failed to load messages:', err); |
| this._showToast('Failed to load messages', 'error'); |
| } |
| } |
|
|
| async _apiGet(endpoint) { |
| try { |
| this._updateConnectionStatus('connecting'); |
| |
| const response = await fetch(`${API_BASE}${endpoint}`, { |
| headers: { 'Authorization': `Bearer ${this.token}` } |
| }); |
| |
| if (response.status === 401) { |
| this._updateConnectionStatus('disconnected'); |
| throw new Error('Unauthorized'); |
| } |
| |
| if (!response.ok) { |
| this._updateConnectionStatus('error'); |
| throw new Error(`HTTP ${response.status}`); |
| } |
| |
| this._updateConnectionStatus('connected'); |
| return response.json(); |
| } catch (err) { |
| this._updateConnectionStatus('error'); |
| throw err; |
| } |
| } |
|
|
| async _apiPost(endpoint, data) { |
| try { |
| this._updateConnectionStatus('connecting'); |
| |
| const response = await fetch(`${API_BASE}${endpoint}`, { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'Authorization': `Bearer ${this.token}` |
| }, |
| body: JSON.stringify(data) |
| }); |
| |
| if (response.status === 401) { |
| this._updateConnectionStatus('disconnected'); |
| throw new Error('Unauthorized'); |
| } |
| |
| if (!response.ok) { |
| this._updateConnectionStatus('error'); |
| throw new Error(`HTTP ${response.status}`); |
| } |
| |
| this._updateConnectionStatus('connected'); |
| return response.json(); |
| } catch (err) { |
| this._updateConnectionStatus('error'); |
| throw err; |
| } |
| } |
| |
| _updateConnectionStatus(status) { |
| if (!this.connectionStatus) return; |
| |
| |
| this.connectionStatus.classList.remove('disconnected', 'reconnecting', 'error'); |
| |
| switch (status) { |
| case 'connected': |
| this.connectionText.textContent = 'Connected'; |
| break; |
| case 'connecting': |
| case 'reconnecting': |
| this.connectionStatus.classList.add('reconnecting'); |
| this.connectionText.textContent = 'Connecting...'; |
| break; |
| case 'disconnected': |
| this.connectionStatus.classList.add('disconnected'); |
| this.connectionText.textContent = 'Disconnected'; |
| break; |
| case 'error': |
| this.connectionStatus.classList.add('disconnected'); |
| this.connectionText.textContent = 'Connection Error'; |
| break; |
| } |
| } |
|
|
| |
| _renderStats() { |
| const s = this.stats; |
| this.totalUsers.textContent = s.totalUsers || 0; |
| this.totalChats.textContent = s.totalChats || 0; |
| this.todayQueries.textContent = s.todayQueries || 0; |
| |
| |
| this._renderTrendIndicator(this.usersTrend, s.totalUsers, s.previousUsers); |
| this._renderTrendIndicator(this.chatsTrend, s.totalChats, s.previousChats); |
| this._renderTrendIndicator(this.todayTrend, s.todayQueries, s.previousTodayQueries); |
| |
| if (this.avgResponseTime) { |
| this.avgResponseTime.textContent = `${s.avgResponseTime || 0}ms`; |
| } |
| if (this.totalMessages) { |
| this.totalMessages.textContent = s.totalMessages || 0; |
| } |
| if (this.activeSessions) { |
| this.activeSessions.textContent = s.activeSessions || 0; |
| } |
| |
| |
| if (this.peakHour && s.peakHour !== undefined) { |
| const hour = s.peakHour; |
| const hourStr = hour === 0 ? '12 AM' : hour < 12 ? `${hour} AM` : hour === 12 ? '12 PM' : `${hour - 12} PM`; |
| this.peakHour.textContent = hourStr; |
| } |
| |
| if (this.onlineUsers) { |
| this.onlineUsers.textContent = s.activeSessions || 0; |
| } |
| |
| if (this.avgChatsPerUser) { |
| this.avgChatsPerUser.textContent = s.avgChatsPerUser || '0'; |
| } |
| |
| |
| if (this.yesterdayQueries) { |
| this.yesterdayQueries.textContent = s.yesterdayQueries || 0; |
| } |
| if (this.weekQueries) { |
| this.weekQueries.textContent = s.weekQueries || 0; |
| } |
| if (this.monthQueries) { |
| this.monthQueries.textContent = s.monthQueries || 0; |
| } |
| if (this.newUsersToday) { |
| this.newUsersToday.textContent = s.newUsersToday || 0; |
| } |
| if (this.newUsersWeek) { |
| this.newUsersWeek.textContent = s.newUsersWeek || 0; |
| } |
| if (this.avgMessagesPerChat) { |
| this.avgMessagesPerChat.textContent = s.avgMessagesPerChat || '0'; |
| } |
| |
| |
| if (this.summaryTotalUsers) { |
| this.summaryTotalUsers.textContent = s.totalUsers || 0; |
| } |
| if (this.summaryOnline) { |
| this.summaryOnline.textContent = s.activeSessions || 0; |
| } |
| if (this.summaryToday) { |
| this.summaryToday.textContent = s.todayQueries || 0; |
| } |
| |
| |
| this._renderSparkline(this.responseTimeSparkline, s.responseTimeHistory); |
| this._renderSparkline(this.messagesSparkline, s.messageHistory); |
| this._renderActivityIndicator(this.activityIndicator, s.activeSessions || 0); |
| |
| |
| if (this.dailyActivityChart && s.dailyActivity) { |
| this._renderDailyActivity(s.dailyActivity); |
| } |
| |
| |
| if (this.browserDistribution && s.browserStats) { |
| this._renderDistribution(this.browserDistribution, s.browserStats, 'browser'); |
| } |
| if (this.osDistribution && s.osStats) { |
| this._renderDistribution(this.osDistribution, s.osStats, 'os'); |
| } |
| if (this.deviceDistribution && s.deviceStats) { |
| this._renderDistribution(this.deviceDistribution, s.deviceStats, 'device'); |
| } |
| if (this.languageDistribution && s.languageStats) { |
| this._renderDistribution(this.languageDistribution, s.languageStats, 'language'); |
| } |
| if (this.refererDistribution && s.refererStats) { |
| this._renderDistribution(this.refererDistribution, s.refererStats, 'referer'); |
| } |
| if (this.countryDistribution && s.countryStats) { |
| this._renderDistribution(this.countryDistribution, s.countryStats, 'country'); |
| } |
| if (this.screenDistribution && s.screenStats) { |
| this._renderDistribution(this.screenDistribution, s.screenStats, 'screen'); |
| } |
| |
| |
| if (this.realtimeOnline) { |
| this.realtimeOnline.textContent = s.activeSessions || 0; |
| } |
| if (this.lastHourQueries) { |
| this.lastHourQueries.textContent = s.lastHourQueries || 0; |
| } |
| if (this.returningUsers) { |
| this.returningUsers.textContent = s.returningUsers || 0; |
| } |
| if (this.returningRate) { |
| this.returningRate.textContent = (s.returningRate || 0) + '%'; |
| } |
| |
| |
| if (this.totalPageViews) { |
| this.totalPageViews.textContent = this._formatNumber(s.totalPageViews || 0); |
| } |
| if (this.avgPageViews) { |
| this.avgPageViews.textContent = s.avgPageViewsPerUser || 0; |
| } |
| if (this.avgSessionDurationEl) { |
| this.avgSessionDurationEl.textContent = (s.avgSessionDuration || 0) + 'm'; |
| } |
| if (this.totalSessionsEl) { |
| this.totalSessionsEl.textContent = this._formatNumber(s.totalSessions || 0); |
| } |
| if (this.bounceRateEl) { |
| this.bounceRateEl.textContent = (s.bounceRate || 0) + '%'; |
| } |
| if (this.newUsersMonthEl) { |
| this.newUsersMonthEl.textContent = s.newUsersMonth || 0; |
| } |
| |
| |
| if (this.totalErrorsEl) { |
| this.totalErrorsEl.textContent = s.totalErrors || 0; |
| } |
| if (this.errorRateEl) { |
| this.errorRateEl.textContent = (s.errorRate || 0) + '%'; |
| } |
| if (this.recentErrorsEl) { |
| this.recentErrorsEl.textContent = s.recentErrors || 0; |
| } |
| if (this.totalTokensEl) { |
| this.totalTokensEl.textContent = this._formatNumber(s.totalTokens || 0); |
| } |
| |
| |
| if (this.topUsersChart && s.topUsers) { |
| this._renderTopUsers(s.topUsers); |
| } |
| |
| |
| if (this.hourlyUsageChart && s.hourlyUsage) { |
| this._renderHourlyUsage(s.hourlyUsage, s.peakHour); |
| } |
| |
| |
| if (this.modelUsageChart && s.modelUsage) { |
| this._renderModelUsage(s.modelUsage); |
| } |
| |
| |
| if (this.systemHealthChart && s.systemHealth) { |
| this._renderSystemHealth(s.systemHealth); |
| } |
| |
| |
| if (this.performanceIndicators && s.systemHealth) { |
| this._renderPerformanceIndicators(s.systemHealth); |
| } |
| |
| |
| if (this.serverInfoChart && s.systemHealth) { |
| this._renderServerInfo(s.systemHealth); |
| } |
| |
| |
| this._renderUserInsights(s); |
| |
| |
| if (this.weeklyHeatmap && s.hourlyUsage) { |
| this._renderWeeklyHeatmap(s.hourlyUsage); |
| } |
| } |
| |
| _renderUserInsights(stats) { |
| |
| if (this.avgEngagement && this.users.length > 0) { |
| const totalEngagement = this.users.reduce((sum, u) => sum + (u.engagementScore || 0), 0); |
| const avgEng = Math.round(totalEngagement / this.users.length); |
| this.avgEngagement.textContent = avgEng + '%'; |
| } |
| |
| |
| if (this.avgTimeOnSite) { |
| const avgTime = stats.avgSessionDuration || 0; |
| this.avgTimeOnSite.textContent = avgTime + 'm'; |
| } |
| |
| |
| if (this.mobilePercent && stats.deviceStats) { |
| const total = Object.values(stats.deviceStats).reduce((a, b) => a + b, 0) || 1; |
| const mobile = (stats.deviceStats['Mobile'] || 0) + (stats.deviceStats['Tablet'] || 0); |
| const mobilePerc = Math.round((mobile / total) * 100); |
| this.mobilePercent.textContent = mobilePerc + '%'; |
| } |
| |
| |
| if (this.avgSessionsPerUser && stats.totalUsers > 0) { |
| const avgSessions = (stats.totalSessions / stats.totalUsers).toFixed(1); |
| this.avgSessionsPerUser.textContent = avgSessions; |
| } |
| } |
| |
| _renderWeeklyHeatmap(hourlyUsage) { |
| if (!this.weeklyHeatmap) return; |
| |
| |
| const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; |
| const maxVal = Math.max(...(hourlyUsage || []), 1); |
| |
| let html = '<div class="heatmap-grid">'; |
| |
| |
| days.forEach((day, dayIndex) => { |
| html += `<div class="heatmap-row">`; |
| html += `<div class="heatmap-day-label">${day}</div>`; |
| |
| |
| for (let hour = 0; hour < 24; hour++) { |
| |
| const baseVal = hourlyUsage ? (hourlyUsage[hour] || 0) : 0; |
| const variation = Math.random() * 0.4 + 0.8; |
| const val = Math.round(baseVal * variation); |
| const level = Math.min(Math.floor((val / maxVal) * 5), 5); |
| |
| html += `<div class="heatmap-cell level-${level}" title="${day} ${hour}:00 - ${val} activities"></div>`; |
| } |
| |
| html += `</div>`; |
| }); |
| |
| html += '</div>'; |
| |
| |
| html += '<div class="heatmap-hour-labels">'; |
| html += '<div></div>'; |
| for (let hour = 0; hour < 24; hour++) { |
| if (hour % 3 === 0) { |
| html += `<div class="heatmap-hour-label">${hour}</div>`; |
| } else { |
| html += '<div></div>'; |
| } |
| } |
| html += '</div>'; |
| |
| |
| html += ` |
| <div class="heatmap-legend"> |
| <span>Less</span> |
| <div class="heatmap-legend-cell level-0"></div> |
| <div class="heatmap-legend-cell level-1"></div> |
| <div class="heatmap-legend-cell level-2"></div> |
| <div class="heatmap-legend-cell level-3"></div> |
| <div class="heatmap-legend-cell level-4"></div> |
| <div class="heatmap-legend-cell level-5"></div> |
| <span>More</span> |
| </div> |
| `; |
| |
| this.weeklyHeatmap.innerHTML = html; |
| } |
| |
| _renderDailyActivity(dailyData) { |
| if (!this.dailyActivityChart || !dailyData || !Array.isArray(dailyData)) return; |
| |
| const maxValue = Math.max(...dailyData.map(d => d.count), 1); |
| |
| let html = '<div class="daily-bars">'; |
| dailyData.forEach(day => { |
| const height = Math.max((day.count / maxValue) * 100, 5); |
| html += ` |
| <div class="daily-bar-wrapper"> |
| <div class="daily-bar" style="height: ${height}%"> |
| <span class="daily-bar-value">${day.count}</span> |
| </div> |
| <span class="daily-bar-label">${day.date.split(',')[0]}</span> |
| </div> |
| `; |
| }); |
| html += '</div>'; |
| |
| this.dailyActivityChart.innerHTML = html; |
| } |
| |
| _renderDistribution(element, data, type) { |
| if (!element || !data) return; |
| |
| const total = Object.values(data).reduce((a, b) => a + b, 0) || 1; |
| const sorted = Object.entries(data).sort(([,a], [,b]) => b - a); |
| |
| const icons = { |
| browser: { |
| 'Chrome': '🌐', 'Firefox': '🦊', 'Safari': '🧭', 'Edge': '📘', |
| 'Opera': '🔴', 'Brave': '🦁', 'Vivaldi': '🎨', 'Unknown': '❓' |
| }, |
| os: { |
| 'Windows': '🪟', 'macOS': '🍎', 'Linux': '🐧', 'Android': '🤖', |
| 'iOS': '📱', 'Chrome OS': '💻', 'Ubuntu': '🟠', 'Unknown': '❓' |
| }, |
| device: { |
| 'Desktop': '🖥️', 'Mobile': '📱', 'Tablet': '📲', 'Unknown': '❓' |
| }, |
| language: { |
| 'en': '🇺🇸', 'es': '🇪🇸', 'fr': '🇫🇷', 'de': '🇩🇪', 'zh': '🇨🇳', |
| 'ja': '🇯🇵', 'ko': '🇰🇷', 'pt': '🇧🇷', 'ru': '🇷🇺', 'ar': '🇸🇦', |
| 'hi': '🇮🇳', 'it': '🇮🇹', 'Unknown': '🌍' |
| } |
| }; |
| |
| const colors = ['#667eea', '#764ba2', '#3eb489', '#f59e0b', '#ef4444', '#06b6d4', '#8b5cf6', '#ec4899']; |
| |
| if (sorted.length === 0) { |
| element.innerHTML = '<div class="no-data" style="padding: 12px; text-align: center; color: var(--text-tertiary); font-size: 12px;">No data available</div>'; |
| return; |
| } |
| |
| let html = ''; |
| sorted.forEach(([name, count], index) => { |
| const percent = Math.round((count / total) * 100); |
| const icon = icons[type]?.[name] || icons[type]?.['Unknown'] || '📊'; |
| const color = colors[index % colors.length]; |
| |
| html += ` |
| <div class="distribution-item"> |
| <span class="distribution-icon">${icon}</span> |
| <span class="distribution-name" title="${this.escapeHtml(name)}">${this.escapeHtml(name)}</span> |
| <div class="distribution-bar"> |
| <div class="distribution-fill" style="width: ${percent}%; background: ${color};"></div> |
| </div> |
| <span class="distribution-count">${count}</span> |
| <span class="distribution-percent">${percent}%</span> |
| </div> |
| `; |
| }); |
| |
| element.innerHTML = html; |
| } |
| |
| _renderTopUsers(topUsers) { |
| if (!this.topUsersChart || !topUsers || topUsers.length === 0) { |
| if (this.topUsersChart) { |
| this.topUsersChart.innerHTML = '<div class="no-data" style="padding: 20px; text-align: center; color: var(--text-tertiary);">No user data yet</div>'; |
| } |
| return; |
| } |
| |
| let html = ''; |
| topUsers.forEach((user, index) => { |
| const rankClass = index === 0 ? 'gold' : index === 1 ? 'silver' : index === 2 ? 'bronze' : ''; |
| html += ` |
| <div class="top-user-item"> |
| <div class="top-user-rank ${rankClass}">${index + 1}</div> |
| <div class="top-user-info"> |
| <div class="top-user-ip">${this.escapeHtml(user.ip)}</div> |
| <div class="top-user-stats"> |
| <div class="top-user-stat">💬 <span>${user.chats}</span> chats</div> |
| <div class="top-user-stat">📝 <span>${user.messages}</span> msgs</div> |
| <div class="top-user-stat">🔄 <span>${user.sessions}</span> sessions</div> |
| </div> |
| </div> |
| </div> |
| `; |
| }); |
| |
| this.topUsersChart.innerHTML = html; |
| } |
| |
| _formatNumber(num) { |
| if (num >= 1000000) { |
| return (num / 1000000).toFixed(1) + 'M'; |
| } else if (num >= 1000) { |
| return (num / 1000).toFixed(1) + 'K'; |
| } |
| return num.toString(); |
| } |
| |
| _renderTrendIndicator(element, current, previous) { |
| if (!element) return; |
| |
| const currentVal = current || 0; |
| const previousVal = previous || 0; |
| |
| if (previousVal === 0) { |
| element.innerHTML = '<div class="trend-arrow neutral"></div><span>--</span>'; |
| element.className = 'stat-trend neutral'; |
| return; |
| } |
| |
| const change = currentVal - previousVal; |
| const percentChange = Math.round((change / previousVal) * 100); |
| |
| if (change > 0) { |
| element.innerHTML = `<div class="trend-arrow up"></div><span>+${percentChange}%</span>`; |
| element.className = 'stat-trend up'; |
| } else if (change < 0) { |
| element.innerHTML = `<div class="trend-arrow down"></div><span>${percentChange}%</span>`; |
| element.className = 'stat-trend down'; |
| } else { |
| element.innerHTML = '<div class="trend-arrow neutral"></div><span>0%</span>'; |
| element.className = 'stat-trend neutral'; |
| } |
| } |
| |
| _renderSparkline(element, data) { |
| if (!element) return; |
| |
| |
| if (!data || !Array.isArray(data) || data.length === 0) { |
| data = Array.from({length: 20}, () => Math.floor(Math.random() * 100)); |
| } |
| |
| const maxValue = Math.max(...data, 1); |
| const bars = data.slice(-20).map(value => { |
| const height = Math.max((value / maxValue) * 16, 2); |
| return `<div class="sparkline-bar" style="--height: ${height}px; height: ${height}px;"></div>`; |
| }).join(''); |
| |
| element.innerHTML = bars; |
| } |
| |
| _renderActivityIndicator(element, sessionCount) { |
| if (!element) return; |
| |
| const maxDots = 8; |
| const activeDots = Math.min(Math.max(sessionCount || 0, 0), maxDots); |
| |
| let html = ''; |
| for (let i = 0; i < maxDots; i++) { |
| const isActive = i < activeDots; |
| html += `<div class="activity-dot ${isActive ? 'active' : ''}"></div>`; |
| } |
| |
| element.innerHTML = html; |
| } |
| |
| _renderPerformanceIndicators(health) { |
| if (!this.performanceIndicators) return; |
| |
| if (!health || !health.memoryUsage) { |
| this.performanceIndicators.innerHTML = ` |
| <div class="perf-indicator"> |
| <div class="perf-dot loading"></div> |
| <span>Loading...</span> |
| </div> |
| `; |
| return; |
| } |
| |
| const memoryPercent = Math.round((health.memoryUsage.heapUsed / health.memoryUsage.heapTotal) * 100); |
| const uptimeHours = Math.floor((health.uptime || 0) / 3600); |
| |
| let memoryStatus = 'excellent'; |
| if (memoryPercent > 80) memoryStatus = 'poor'; |
| else if (memoryPercent > 60) memoryStatus = 'fair'; |
| else if (memoryPercent > 40) memoryStatus = 'good'; |
| |
| let uptimeStatus = 'excellent'; |
| if (uptimeHours < 1) uptimeStatus = 'poor'; |
| else if (uptimeHours < 24) uptimeStatus = 'fair'; |
| else if (uptimeHours < 168) uptimeStatus = 'good'; |
| |
| const html = ` |
| <div class="perf-indicator"> |
| <div class="perf-dot ${memoryStatus}"></div> |
| <span>Memory ${memoryPercent}%</span> |
| </div> |
| <div class="perf-indicator"> |
| <div class="perf-dot ${uptimeStatus}"></div> |
| <span>Uptime ${uptimeHours}h</span> |
| </div> |
| `; |
| |
| this.performanceIndicators.innerHTML = html; |
| } |
| |
| _renderServerInfo(health) { |
| if (!this.serverInfoChart) return; |
| |
| if (!health) { |
| this.serverInfoChart.innerHTML = '<div class="no-data">No server info available</div>'; |
| return; |
| } |
| |
| const uptimeSeconds = health.uptime || 0; |
| const uptimeDays = Math.floor(uptimeSeconds / 86400); |
| const uptimeHours = Math.floor((uptimeSeconds % 86400) / 3600); |
| const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60); |
| |
| let uptimeStr = ''; |
| if (uptimeDays > 0) uptimeStr += `${uptimeDays}d `; |
| if (uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeHours}h `; |
| uptimeStr += `${uptimeMinutes}m`; |
| |
| const memoryMB = health.memoryUsage ? Math.round(health.memoryUsage.heapUsed / 1024 / 1024) : 0; |
| const memoryTotalMB = health.memoryUsage ? Math.round(health.memoryUsage.heapTotal / 1024 / 1024) : 0; |
| const rssMB = health.memoryUsage ? Math.round(health.memoryUsage.rss / 1024 / 1024) : 0; |
| const externalMB = health.memoryUsage ? Math.round((health.memoryUsage.external || 0) / 1024 / 1024) : 0; |
| |
| const html = ` |
| <div class="server-info-item"> |
| <span class="server-info-label">Node.js Version</span> |
| <span class="server-info-value">${this.escapeHtml(health.nodeVersion || 'Unknown')}</span> |
| </div> |
| <div class="server-info-item"> |
| <span class="server-info-label">Server Uptime</span> |
| <span class="server-info-value">${uptimeStr}</span> |
| </div> |
| <div class="server-info-item"> |
| <span class="server-info-label">Heap Memory</span> |
| <span class="server-info-value">${memoryMB}MB / ${memoryTotalMB}MB</span> |
| </div> |
| <div class="server-info-item"> |
| <span class="server-info-label">RSS Memory</span> |
| <span class="server-info-value">${rssMB}MB</span> |
| </div> |
| <div class="server-info-item"> |
| <span class="server-info-label">External Memory</span> |
| <span class="server-info-value">${externalMB}MB</span> |
| </div> |
| <div class="server-info-item"> |
| <span class="server-info-label">Platform</span> |
| <span class="server-info-value">${this.escapeHtml(health.platform || process?.platform || 'Unknown')}</span> |
| </div> |
| `; |
| |
| this.serverInfoChart.innerHTML = html; |
| } |
| |
| _renderHourlyUsage(hourlyData, peakHour) { |
| if (!this.hourlyUsageChart) return; |
| |
| if (!hourlyData || !Array.isArray(hourlyData) || hourlyData.length === 0) { |
| |
| hourlyData = new Array(24).fill(0); |
| } |
| |
| const maxValue = Math.max(...hourlyData, 1); |
| |
| let barsHtml = ''; |
| for (let i = 0; i < 24; i++) { |
| const value = hourlyData[i] || 0; |
| const height = Math.max((value / maxValue) * 100, 2); |
| const isPeak = i === peakHour; |
| barsHtml += `<div class="hourly-bar ${isPeak ? 'peak' : ''}" style="height: ${height}%" title="${i}:00 - ${value} requests"></div>`; |
| } |
| |
| const html = ` |
| <div class="hourly-bars">${barsHtml}</div> |
| <div class="hourly-labels"> |
| <span class="hourly-label">12AM</span> |
| <span class="hourly-label">6AM</span> |
| <span class="hourly-label">12PM</span> |
| <span class="hourly-label">6PM</span> |
| <span class="hourly-label">11PM</span> |
| </div> |
| `; |
| |
| this.hourlyUsageChart.innerHTML = html; |
| } |
|
|
| _renderModelUsage(usage) { |
| const total = Object.values(usage).reduce((a, b) => a + b, 0) || 1; |
| |
| |
| const sortedUsage = Object.entries(usage).sort(([,a], [,b]) => b - a); |
| |
| let html = ''; |
| |
| for (const [model, count] of sortedUsage) { |
| const percent = Math.round((count / total) * 100); |
| const barColor = this._getModelColor(model); |
| |
| html += ` |
| <div class="usage-bar fade-in"> |
| <span class="usage-bar-label" title="${this.escapeHtml(model)}">${this.escapeHtml(model)}</span> |
| <div class="usage-bar-track"> |
| <div class="usage-bar-fill" style="width: ${percent}%; background: ${barColor}"></div> |
| </div> |
| <span class="usage-bar-value">${count} (${percent}%)</span> |
| </div> |
| `; |
| } |
| |
| this.modelUsageChart.innerHTML = html || '<div class="no-data">No usage data</div>'; |
| } |
| |
| _getModelColor(model) { |
| |
| const colors = [ |
| 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', |
| 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', |
| 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', |
| 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)', |
| 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' |
| ]; |
| |
| |
| let hash = 0; |
| for (let i = 0; i < model.length; i++) { |
| hash = ((hash << 5) - hash + model.charCodeAt(i)) & 0xffffffff; |
| } |
| return colors[Math.abs(hash) % colors.length]; |
| } |
| |
| _renderSystemHealth(health) { |
| if (!health) { |
| this.systemHealthChart.innerHTML = '<div class="no-data">No health data</div>'; |
| return; |
| } |
| |
| const memoryMB = Math.round(health.memoryUsage.heapUsed / 1024 / 1024); |
| const memoryTotalMB = Math.round(health.memoryUsage.heapTotal / 1024 / 1024); |
| const memoryPercent = Math.round((health.memoryUsage.heapUsed / health.memoryUsage.heapTotal) * 100); |
| const uptimeHours = Math.floor(health.uptime / 3600); |
| const uptimeDays = Math.floor(uptimeHours / 24); |
| |
| let uptimeText = ''; |
| if (uptimeDays > 0) { |
| uptimeText = `${uptimeDays}d ${uptimeHours % 24}h`; |
| } else { |
| uptimeText = `${uptimeHours}h`; |
| } |
| |
| const healthColor = memoryPercent > 80 ? '#ef4444' : memoryPercent > 60 ? '#f59e0b' : '#10b981'; |
| |
| const html = ` |
| <div class="health-metrics fade-in"> |
| <div class="health-metric"> |
| <div class="health-metric-label">Memory Usage</div> |
| <div class="health-metric-value">${memoryMB}MB / ${memoryTotalMB}MB</div> |
| <div class="health-progress"> |
| <div class="health-progress-fill" style="width: ${memoryPercent}%; background: ${healthColor}"></div> |
| </div> |
| <div class="health-metric-percent">${memoryPercent}%</div> |
| </div> |
| <div class="health-metric"> |
| <div class="health-metric-label">Uptime</div> |
| <div class="health-metric-value">${uptimeText}</div> |
| </div> |
| <div class="health-metric"> |
| <div class="health-metric-label">Node.js Version</div> |
| <div class="health-metric-value">${health.nodeVersion}</div> |
| </div> |
| </div> |
| `; |
| |
| this.systemHealthChart.innerHTML = html; |
| } |
|
|
| _renderUsers() { |
| if (!this.users.length) { |
| this.userList.innerHTML = ` |
| <div class="no-data enhanced-empty-state"> |
| <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> |
| <path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/> |
| <circle cx="9" cy="7" r="4"/> |
| <path d="M23 21v-2a4 4 0 00-3-3.87"/> |
| <path d="M16 3.13a4 4 0 010 7.75"/> |
| </svg> |
| <h3>No Users Yet</h3> |
| <p>Users will appear here when they start chatting with Rox AI</p> |
| <button class="btn btn-secondary btn-sm" onclick="window.open('/', '_blank')"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/> |
| <polyline points="15 3 21 3 21 9"/> |
| <line x1="10" y1="14" x2="21" y2="3"/> |
| </svg> |
| Open Chat Interface |
| </button> |
| </div> |
| `; |
| return; |
| } |
| |
| |
| const sortedUsers = [...this.users].sort((a, b) => { |
| if (a.isOnline && !b.isOnline) return -1; |
| if (!a.isOnline && b.isOnline) return 1; |
| return (b.lastActivity || 0) - (a.lastActivity || 0); |
| }); |
| |
| |
| if (sortedUsers.length > 100) { |
| this._renderUsersVirtual(sortedUsers); |
| return; |
| } |
| |
| let html = ''; |
| for (const user of sortedUsers) { |
| html += this._renderUserCard(user); |
| } |
| |
| this.userList.innerHTML = html; |
| this._attachUserEventListeners(); |
| } |
| |
| _renderUserCard(user) { |
| const isActive = this.currentUser === user.ip; |
| const isOnline = user.isOnline ? 'online' : ''; |
| const timeAgo = this._formatTimeAgo(user.lastActivity); |
| const firstSeenDate = user.firstSeen ? new Date(user.firstSeen).toLocaleDateString() : 'Unknown'; |
| const engagementScore = user.engagementScore || 0; |
| const engagementClass = engagementScore >= 70 ? 'high' : engagementScore >= 40 ? 'medium' : 'low'; |
| |
| |
| const userType = user.userType || 'New'; |
| const userTypeClass = userType === 'Power User' ? 'power' : userType === 'Returning' ? 'returning' : userType === 'Dormant' ? 'dormant' : 'new'; |
| |
| |
| const bounceStatus = user.bounceStatus || ((user.pageViews || 1) === 1 ? 'Bounced' : 'Engaged'); |
| const bounceClass = bounceStatus === 'Bounced' ? 'bounced' : 'engaged'; |
| |
| return ` |
| <div class="user-card ${isActive ? 'active' : ''} fade-in" data-ip="${this.escapeHtml(user.ip)}" tabindex="0" role="button" aria-label="Select user ${user.ip}"> |
| <div class="user-card-header"> |
| <span class="user-status ${isOnline} ${isOnline ? 'status-indicator' : ''}"></span> |
| <span class="user-ip" title="Full IP Address: ${this.escapeHtml(user.ip)}">${this.escapeHtml(user.ip)}</span> |
| <span class="user-type-badge ${userTypeClass}">${userType}</span> |
| <span class="user-time">${timeAgo}</span> |
| </div> |
| <div class="user-meta"> |
| <span class="user-chat-count">${user.chatCount || 0} chats</span> |
| <span class="user-badge messages">${user.totalMessages || 0} msgs</span> |
| <span class="user-badge visits">👁 ${user.visits || 1}</span> |
| ${user.avgResponseTime ? `<span class="user-badge response-time" title="Avg Response Time">⚡ ${user.avgResponseTime}ms</span>` : ''} |
| <span class="user-badge bounce ${bounceClass}" title="Bounce Status">${bounceStatus === 'Bounced' ? '🚪' : '✅'} ${bounceStatus}</span> |
| </div> |
| <div class="user-device-info"> |
| ${user.device?.browser ? `<span class="user-badge browser" title="${user.userAgentRaw ? this.escapeHtml(user.userAgentRaw) : 'Browser: ' + this.escapeHtml(user.device.fullBrowser || user.device.browser)}">${this.escapeHtml(user.device.fullBrowser || user.device.browser)}</span>` : ''} |
| ${user.device?.os ? `<span class="user-badge os" title="Operating System">${this.escapeHtml(user.device.fullOs || user.device.os)}</span>` : ''} |
| ${user.device?.device ? `<span class="user-badge device" title="Device Type">${this.escapeHtml(user.device.device)}</span>` : ''} |
| </div> |
| <div class="user-card-extra"> |
| ${user.country ? `<span class="user-extra-badge country" title="Country">🌍 ${this.escapeHtml(user.country)}</span>` : ''} |
| ${user.city ? `<span class="user-extra-badge city" title="City">📍 ${this.escapeHtml(user.city)}</span>` : ''} |
| ${user.screenResolution ? `<span class="user-extra-badge screen" title="Screen Resolution">📐 ${this.escapeHtml(user.screenResolution)}</span>` : ''} |
| ${user.totalTokensUsed ? `<span class="user-extra-badge tokens" title="Tokens Used">🪙 ${this._formatNumber(user.totalTokensUsed)}</span>` : ''} |
| ${user.favoriteModel ? `<span class="user-extra-badge model" title="Favorite Model">⭐ ${this.escapeHtml(user.favoriteModel)}</span>` : ''} |
| ${user.errorCount > 0 ? `<span class="user-extra-badge error" title="Errors">❌ ${user.errorCount}</span>` : ''} |
| ${user.colorScheme ? `<span class="user-extra-badge theme" title="Color Scheme">${user.colorScheme === 'dark' ? '🌙' : '☀️'} ${this.escapeHtml(user.colorScheme)}</span>` : ''} |
| ${user.doNotTrack ? `<span class="user-extra-badge dnt" title="Do Not Track">🛡️ DNT</span>` : ''} |
| </div> |
| <div class="user-details"> |
| <span class="user-detail-item" title="First seen">🆕 ${firstSeenDate}</span> |
| <span class="user-detail-item" title="Sessions">🔄 ${user.sessionCount || 1}</span> |
| ${user.avgSessionDuration ? `<span class="user-detail-item" title="Avg Session Duration">⏱️ ${user.avgSessionDuration}m</span>` : ''} |
| ${user.totalSessionTime ? `<span class="user-detail-item" title="Total Time">⏳ ${user.totalSessionTime}m</span>` : ''} |
| ${user.language && user.language !== 'Unknown' ? `<span class="user-detail-item" title="Language">🗣️ ${this.escapeHtml(user.language)}</span>` : ''} |
| ${user.peakActivityHour !== null ? `<span class="user-detail-item" title="Peak Activity Hour">🕐 ${user.peakActivityHour}:00</span>` : ''} |
| ${user.pageViews ? `<span class="user-detail-item" title="Page Views">📄 ${user.pageViews} views</span>` : ''} |
| ${user.requestCount ? `<span class="user-detail-item" title="Total Requests">📊 ${user.requestCount} reqs</span>` : ''} |
| <span class="user-detail-item ${isOnline ? 'online-status' : 'offline-status'}">${isOnline ? '🟢 Online' : '⚫ Offline'}</span> |
| </div> |
| ${user.referer && user.referer !== 'Direct' ? ` |
| <div class="user-referer"> |
| <span class="referer-label">Source:</span> |
| <span class="referer-value">${this.escapeHtml(user.referer)}</span> |
| </div> |
| ` : ''} |
| ${user.lastChatTitle ? ` |
| <div class="user-referer"> |
| <span class="referer-label">Last Chat:</span> |
| <span class="referer-value" title="${this.escapeHtml(user.lastChatTitle)}">${this.escapeHtml(user.lastChatTitle.length > 40 ? user.lastChatTitle.substring(0, 40) + '...' : user.lastChatTitle)}</span> |
| </div> |
| ` : ''} |
| ${user.platform ? ` |
| <div class="user-referer"> |
| <span class="referer-label">Platform:</span> |
| <span class="referer-value">${this.escapeHtml(user.platform)}</span> |
| </div> |
| ` : ''} |
| ${user.connectionType ? ` |
| <div class="user-referer"> |
| <span class="referer-label">Connection:</span> |
| <span class="referer-value">${this.escapeHtml(user.connectionType)}</span> |
| </div> |
| ` : ''} |
| <div class="user-engagement"> |
| <div class="engagement-bar"> |
| <div class="engagement-fill ${engagementClass}" style="width: ${engagementScore}%"></div> |
| </div> |
| <span class="engagement-label">Engagement: ${engagementScore}%</span> |
| </div> |
| ${user.activityByHour && user.activityByHour.some(v => v > 0) ? ` |
| <div class="user-activity-mini"> |
| <span class="activity-mini-label">Activity Pattern:</span> |
| <div class="activity-mini-chart"> |
| ${this._renderMiniActivityChart(user.activityByHour)} |
| </div> |
| </div> |
| ` : ''} |
| </div> |
| `; |
| } |
| |
| _renderMiniActivityChart(activityByHour) { |
| if (!activityByHour || !Array.isArray(activityByHour)) return ''; |
| const max = Math.max(...activityByHour, 1); |
| return activityByHour.map((val, hour) => { |
| const height = Math.max((val / max) * 100, 5); |
| const isPeak = val === max && val > 0; |
| return `<div class="mini-bar ${isPeak ? 'peak' : ''}" style="height: ${height}%" title="${hour}:00 - ${val} actions"></div>`; |
| }).join(''); |
| } |
| |
| _renderUsersVirtual(users) { |
| |
| const ITEM_HEIGHT = 76; |
| const VISIBLE_ITEMS = Math.ceil(this.userList.clientHeight / ITEM_HEIGHT) + 5; |
| |
| let startIndex = 0; |
| const endIndex = Math.min(startIndex + VISIBLE_ITEMS, users.length); |
| |
| let html = ''; |
| for (let i = startIndex; i < endIndex; i++) { |
| html += this._renderUserCard(users[i]); |
| } |
| |
| |
| if (endIndex < users.length) { |
| const remainingHeight = (users.length - endIndex) * ITEM_HEIGHT; |
| html += `<div style="height: ${remainingHeight}px; display: flex; align-items: center; justify-content: center; color: var(--text-tertiary); font-size: 12px;">+${users.length - endIndex} more users</div>`; |
| } |
| |
| this.userList.innerHTML = html; |
| this._attachUserEventListeners(); |
| } |
| |
| _attachUserEventListeners() { |
| |
| this.userList.querySelectorAll('.user-card').forEach(card => { |
| card.addEventListener('click', () => this._selectUser(card.dataset.ip)); |
| card.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter' || e.key === ' ') { |
| e.preventDefault(); |
| this._selectUser(card.dataset.ip); |
| } |
| }); |
| }); |
| } |
|
|
| _renderChats() { |
| if (!this.chats.length) { |
| this.chatsList.innerHTML = ` |
| <div class="empty-state"> |
| <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> |
| <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/> |
| </svg> |
| <p>No chats found for this user</p> |
| </div> |
| `; |
| return; |
| } |
| |
| |
| const sortedChats = [...this.chats].sort((a, b) => (b.lastMessage || 0) - (a.lastMessage || 0)); |
| |
| let html = ''; |
| for (const chat of sortedChats) { |
| const isActive = this.currentChat === chat.id; |
| const timeAgo = this._formatTimeAgo(chat.lastMessage); |
| const title = chat.title || 'Untitled Chat'; |
| |
| html += ` |
| <div class="chat-card ${isActive ? 'active' : ''} fade-in" data-id="${this.escapeHtml(chat.id)}" tabindex="0" role="button" aria-label="Select chat ${title}"> |
| <div class="chat-card-title">${this.escapeHtml(title)}</div> |
| <div class="chat-card-meta"> |
| <span class="chat-card-status ${chat.isActive ? 'active status-indicator' : ''}"></span> |
| <span>${chat.messageCount || 0} messages</span> |
| <span>·</span> |
| <span>${timeAgo}</span> |
| </div> |
| </div> |
| `; |
| } |
| |
| this.chatsList.innerHTML = html; |
| |
| |
| this.chatsList.querySelectorAll('.chat-card').forEach(card => { |
| card.addEventListener('click', () => this._selectChat(card.dataset.id)); |
| card.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter' || e.key === ' ') { |
| e.preventDefault(); |
| this._selectChat(card.dataset.id); |
| } |
| }); |
| }); |
| } |
|
|
| _renderMessages(messages, chatInfo) { |
| if (!messages.length) { |
| this.messagesArea.innerHTML = ` |
| <div class="empty-state"> |
| <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> |
| <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/> |
| </svg> |
| <p>No messages in this chat</p> |
| </div> |
| `; |
| return; |
| } |
| |
| |
| this.chatHeader.style.display = 'flex'; |
| this.chatTitle.textContent = chatInfo?.title || 'Chat'; |
| this.chatMeta.textContent = `Started ${this._formatDate(chatInfo?.createdAt)} · ${messages.length} messages`; |
| |
| let html = ''; |
| for (const msg of messages) { |
| html += this._renderMessage(msg); |
| } |
| |
| this.messagesArea.innerHTML = html; |
| this._initCodeCopyButtons(); |
| this.messagesArea.scrollTop = this.messagesArea.scrollHeight; |
| } |
|
|
| _renderMessage(msg) { |
| const isUser = msg.role === 'user'; |
| const avatar = isUser ? 'U' : 'R'; |
| const roleName = isUser ? 'User' : 'Rox'; |
| const time = this._formatTime(msg.timestamp); |
| |
| |
| let attachmentsHtml = ''; |
| if (msg.attachments?.length) { |
| attachmentsHtml = '<div class="message-attachments">'; |
| for (const att of msg.attachments) { |
| attachmentsHtml += ` |
| <div class="attachment-chip"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/> |
| <polyline points="14 2 14 8 20 8"/> |
| </svg> |
| ${this.escapeHtml(att.name)} |
| </div> |
| `; |
| } |
| attachmentsHtml += '</div>'; |
| } |
| |
| |
| let modelBadge = ''; |
| let internetBadge = ''; |
| if (!isUser) { |
| if (msg.model) { |
| modelBadge = `<span class="model-badge">${this.escapeHtml(msg.model)}</span>`; |
| } |
| if (msg.usedInternet) { |
| internetBadge = `<span class="internet-badge">🌐 ${this.escapeHtml(msg.internetSource || 'Web')}</span>`; |
| } |
| } |
| |
| |
| let durationHtml = ''; |
| if (!isUser && msg.duration) { |
| durationHtml = `<span class="message-time">${(msg.duration / 1000).toFixed(1)}s</span>`; |
| } |
| |
| return ` |
| <div class="message ${isUser ? 'user' : 'assistant'} fade-in"> |
| <div class="message-avatar">${avatar}</div> |
| <div class="message-wrapper"> |
| <div class="message-header"> |
| <span class="message-role">${roleName}</span> |
| ${modelBadge} |
| ${internetBadge} |
| <span class="message-time">${time}</span> |
| ${durationHtml} |
| </div> |
| ${attachmentsHtml} |
| <div class="message-content">${this._formatContent(msg.content)}</div> |
| </div> |
| </div> |
| `; |
| } |
|
|
| |
| _formatContent(content) { |
| if (!content || typeof content !== 'string') return ''; |
| |
| let formatted = content; |
| |
| |
| formatted = formatted.replace(/^🌐\s*Searching for.*?\.\.\.?\s*$/gm, ''); |
| formatted = formatted.replace(/^🌐\s*LIVE INTERNET SEARCH RESULTS:?\s*$/gm, ''); |
| formatted = formatted.replace(/^\s*\n+/g, '').replace(/\n{3,}/g, '\n\n'); |
| |
| |
| |
| |
| |
| |
| formatted = formatted.replace(/^(\d+)([A-Z][a-zA-Z])/gm, '$1. $2'); |
| |
| formatted = formatted.replace(/^(\d+)(\*\*[^*]+\*\*)/gm, '$1. $2'); |
| |
| formatted = formatted.replace(/^(\d+)([a-zA-Z])/gm, '$1. $2'); |
| |
| formatted = formatted.replace(/^(\d+)\.([^\s\d])/gm, '$1. $2'); |
| |
| formatted = formatted.replace(/^(\d+)\)\s*/gm, '$1. '); |
| |
| formatted = formatted.replace(/^(\d+)-\s*/gm, '$1. '); |
| |
| |
| formatted = formatted.replace(/(\s)(\d+)([A-Z][a-zA-Z])/g, '$1$2. $3'); |
| formatted = formatted.replace(/(\s)(\d+)([a-z])/g, '$1$2. $3'); |
| |
| |
| formatted = formatted.replace(/([a-zA-Z])(\d+)([A-Z][a-zA-Z])/g, '$1\n$2. $3'); |
| formatted = formatted.replace(/([a-z])(\d+)([a-z])/g, '$1 $2. $3'); |
| |
| |
| formatted = formatted.replace(/^-([A-Za-z])/gm, '- $1'); |
| formatted = formatted.replace(/^\*([A-Za-z])/gm, '* $1'); |
| formatted = formatted.replace(/^•([A-Za-z])/gm, '• $1'); |
| |
| |
| formatted = formatted.replace(/\n(\d+)([A-Z])/g, '\n$1. $2'); |
| formatted = formatted.replace(/\n(\d+)([a-z])/g, '\n$1. $2'); |
| |
| formatted = formatted.replace(/\*\*(\d+)([A-Z][a-zA-Z])/g, '**$1. $2'); |
| formatted = formatted.replace(/\*\*(\d+)([a-z])/g, '**$1. $2'); |
| |
| |
| const mathBlocks = []; |
| const inlineMath = []; |
| |
| |
| formatted = formatted.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => { |
| const placeholder = `__MATH_BLOCK_${mathBlocks.length}__`; |
| mathBlocks.push({ math: math.trim(), display: true }); |
| return placeholder; |
| }); |
| formatted = formatted.replace(/\\\[([\s\S]*?)\\\]/g, (_, math) => { |
| const placeholder = `__MATH_BLOCK_${mathBlocks.length}__`; |
| mathBlocks.push({ math: math.trim(), display: true }); |
| return placeholder; |
| }); |
| |
| |
| formatted = formatted.replace(/\$([^\$\n]+?)\$/g, (_, math) => { |
| const placeholder = `__INLINE_MATH_${inlineMath.length}__`; |
| inlineMath.push({ math: math.trim(), display: false }); |
| return placeholder; |
| }); |
| formatted = formatted.replace(/\\\((.+?)\\\)/gs, (_, math) => { |
| const placeholder = `__INLINE_MATH_${inlineMath.length}__`; |
| inlineMath.push({ math: math.trim(), display: false }); |
| return placeholder; |
| }); |
| |
| |
| const codeBlocks = []; |
| formatted = formatted.replace(/```(\w+)?\n?([\s\S]*?)```/g, (_, lang, code) => { |
| const trimmedCode = code.trim(); |
| const language = lang ? lang.toLowerCase() : ''; |
| |
| |
| const hasLaTeXCommands = /\\(?:frac|sqrt|sum|int|lim|prod|infty|alpha|beta|gamma|delta|epsilon|theta|pi|sigma|omega|phi|psi|lambda|mu|nu|rho|tau|eta|zeta|xi|kappa|chi|pm|mp|times|div|cdot|leq|geq|neq|le|ge|ne|approx|equiv|sim|propto|subset|supset|subseteq|supseteq|cup|cap|in|notin|forall|exists|partial|nabla|vec|hat|bar|dot|ddot|text|mathrm|mathbf|mathit|mathbb|mathcal|left|right|big|Big|bigg|Bigg|begin|end|quad|qquad|rightarrow|leftarrow|Rightarrow|Leftarrow|to|gets)\b/.test(trimmedCode); |
| const hasCodePatterns = /(?:function|const|let|var|return|import|export|class|def|if|else|for|while|switch|case|try|catch|=>|===|!==|\|\||&&|console\.|print\(|System\.|public\s|private\s|void\s)/.test(trimmedCode); |
| |
| const isLaTeX = (language === 'latex' || language === 'tex' || language === 'math') || |
| ((language === '' || language === 'plaintext') && hasLaTeXCommands && !hasCodePatterns); |
| |
| if (isLaTeX) { |
| const placeholder = `__MATH_BLOCK_${mathBlocks.length}__`; |
| mathBlocks.push({ math: trimmedCode, display: true }); |
| return placeholder; |
| } |
| |
| const displayLang = lang ? (LANGUAGE_NAMES[language] || language) : 'plaintext'; |
| const escapedCode = this.escapeHtml(trimmedCode); |
| const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`; |
| codeBlocks.push(`<div class="code-block"><div class="code-header"><span class="code-language">${displayLang}</span><button class="code-copy-btn" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg><span>Copy</span></button></div><div class="code-content"><code>${escapedCode}</code></div></div>`); |
| return placeholder; |
| }); |
| |
| |
| const inlineCodes = []; |
| formatted = formatted.replace(/`([^`]+)`/g, (_, code) => { |
| const placeholder = `__INLINE_CODE_${inlineCodes.length}__`; |
| inlineCodes.push(`<code>${this.escapeHtml(code)}</code>`); |
| return placeholder; |
| }); |
| |
| |
| formatted = this._autoFormatMathExpressions(formatted); |
| |
| |
| const tablePlaceholders = []; |
| formatted = this._parseMarkdownTables(formatted, tablePlaceholders); |
| |
| |
| formatted = formatted.replace(/<h([1-6])[^&]*>([\s\S]*?)<\/h\1>/gi, (_, level, text) => `${'#'.repeat(parseInt(level))} ${text.trim()}`); |
| formatted = formatted.replace(/<strong[^&]*>([\s\S]*?)<\/strong>/gi, '**$1**'); |
| formatted = formatted.replace(/<b[^&]*>([\s\S]*?)<\/b>/gi, '**$1**'); |
| formatted = formatted.replace(/<em[^&]*>([\s\S]*?)<\/em>/gi, '*$1*'); |
| formatted = formatted.replace(/<i[^&]*>([\s\S]*?)<\/i>/gi, '*$1*'); |
| formatted = formatted.replace(/<u[^&]*>([\s\S]*?)<\/u>/gi, '_$1_'); |
| formatted = formatted.replace(/<br[^&]*\/?>/gi, '\n'); |
| formatted = formatted.replace(/<span[^&]*>([\s\S]*?)<\/span>/gi, '$1'); |
| |
| |
| let prev = ''; |
| let maxIter = 10; |
| while (prev !== formatted && maxIter-- > 0) { |
| prev = formatted; |
| formatted = formatted.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, text) => `${'#'.repeat(parseInt(level))} ${text.trim()}`); |
| formatted = formatted.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, '**$1**'); |
| formatted = formatted.replace(/<b[^>]*>([\s\S]*?)<\/b>/gi, '**$1**'); |
| formatted = formatted.replace(/<em[^>]*>([\s\S]*?)<\/em>/gi, '*$1*'); |
| formatted = formatted.replace(/<i[^>]*>([\s\S]*?)<\/i>/gi, '*$1*'); |
| formatted = formatted.replace(/<u[^>]*>([\s\S]*?)<\/u>/gi, '_$1_'); |
| formatted = formatted.replace(/<br[^>]*\/?>/gi, '\n'); |
| formatted = formatted.replace(/<span[^>]*>([\s\S]*?)<\/span>/gi, '$1'); |
| } |
| |
| |
| formatted = formatted.replace(/^###### (.+)$/gm, (_, text) => `<h6>${this._formatInlineContent(text)}</h6>`); |
| formatted = formatted.replace(/^##### (.+)$/gm, (_, text) => `<h5>${this._formatInlineContent(text)}</h5>`); |
| formatted = formatted.replace(/^#### (.+)$/gm, (_, text) => `<h4>${this._formatInlineContent(text)}</h4>`); |
| formatted = formatted.replace(/^### (.+)$/gm, (_, text) => `<h3>${this._formatInlineContent(text)}</h3>`); |
| formatted = formatted.replace(/^## (.+)$/gm, (_, text) => `<h2>${this._formatInlineContent(text)}</h2>`); |
| formatted = formatted.replace(/^# (.+)$/gm, (_, text) => `<h1>${this._formatInlineContent(text)}</h1>`); |
| |
| |
| formatted = formatted.replace(/\*\*([^*]+)\*\*/g, (_, text) => `<strong>${this.escapeHtmlDisplay(text)}</strong>`); |
| formatted = formatted.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, (_, text) => `<em>${this.escapeHtmlDisplay(text)}</em>`); |
| formatted = formatted.replace(/(?<![_\w])_([^_]+)_(?![_\w])/g, (match, text) => { |
| if (/__/.test(match)) return match; |
| return `<u>${this.escapeHtmlDisplay(text)}</u>`; |
| }); |
| |
| |
| formatted = formatted.replace(/^(\d+)\. (.+)$/gm, (_, num, text) => `<li data-num="${num}">${this._formatInlineContent(text)}</li>`); |
| formatted = formatted.replace(/((?:<li data-num="\d+">[\s\S]*?<\/li>\n?)+)/g, '<ol>$1</ol>'); |
| formatted = formatted.replace(/<\/ol>\n<ol>/g, ''); |
| formatted = formatted.replace(/ data-num="\d+"/g, ''); |
| |
| formatted = formatted.replace(/^[-*] (.+)$/gm, (_, text) => `<uli>${this._formatInlineContent(text)}</uli>`); |
| formatted = formatted.replace(/((?:<uli>[\s\S]*?<\/uli>\n?)+)/g, '<ul>$1</ul>'); |
| formatted = formatted.replace(/<\/ul>\n<ul>/g, ''); |
| formatted = formatted.replace(/<uli>/g, '<li>'); |
| formatted = formatted.replace(/<\/uli>/g, '</li>'); |
| |
| |
| formatted = formatted.replace(/^> (.+)$/gm, (_, text) => `<blockquote>${this._formatInlineContent(text)}</blockquote>`); |
| formatted = formatted.replace(/<\/blockquote>\n<blockquote>/g, '<br>'); |
| |
| |
| formatted = formatted.replace(/^---$/gm, '<hr>'); |
| formatted = formatted.replace(/^\*\*\*$/gm, '<hr>'); |
| formatted = formatted.replace(/^___$/gm, '<hr>'); |
| |
| |
| formatted = formatted.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => { |
| const safeUrl = this._sanitizeUrl(url); |
| if (!safeUrl) return this.escapeHtmlDisplay(text); |
| return `<a href="${this.escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${this.escapeHtmlDisplay(text)}</a>`; |
| }); |
| |
| |
| const lines = formatted.split('\n'); |
| let result = []; |
| let paragraphContent = []; |
| |
| for (const line of lines) { |
| const trimmed = line.trim(); |
| const isBlockElement = /^<(h[1-6]|ul|ol|li|blockquote|hr|div|pre|__CODE|__TABLE|__MATH)/.test(trimmed) || |
| /<\/(h[1-6]|ul|ol|blockquote|div|pre)>$/.test(trimmed); |
| |
| if (isBlockElement || trimmed === '') { |
| if (paragraphContent.length > 0) { |
| result.push('<p>' + paragraphContent.join('<br>') + '</p>'); |
| paragraphContent = []; |
| } |
| if (trimmed !== '') result.push(line); |
| } else { |
| paragraphContent.push(trimmed); |
| } |
| } |
| |
| if (paragraphContent.length > 0) { |
| result.push('<p>' + paragraphContent.join('<br>') + '</p>'); |
| } |
| |
| formatted = result.join('\n'); |
| |
| |
| inlineCodes.forEach((code, i) => { |
| formatted = formatted.replace(new RegExp(`__INLINE_CODE_${i}__`, 'g'), code); |
| }); |
| codeBlocks.forEach((block, i) => { |
| formatted = formatted.replace(new RegExp(`__CODE_BLOCK_${i}__`, 'g'), block); |
| }); |
| tablePlaceholders.forEach((table, i) => { |
| formatted = formatted.replace(new RegExp(`__TABLE_BLOCK_${i}__`, 'g'), table); |
| }); |
| mathBlocks.forEach((item, i) => { |
| const rendered = this._renderMath(item.math, true); |
| formatted = formatted.replace(new RegExp(`__MATH_BLOCK_${i}__`, 'g'), rendered); |
| }); |
| inlineMath.forEach((item, i) => { |
| const rendered = this._renderMath(item.math, false); |
| formatted = formatted.replace(new RegExp(`__INLINE_MATH_${i}__`, 'g'), rendered); |
| }); |
| |
| |
| formatted = formatted.replace(/<p><br><\/p>/g, ''); |
| formatted = formatted.replace(/<p>\s*<\/p>/g, ''); |
| |
| |
| formatted = formatted.replace(/<\s*h([1-6])\s*>([\s\S]*?)<\s*\/\s*h\1\s*>/gi, (_, level, text) => `<h${level}>${text.trim()}</h${level}>`); |
| formatted = formatted.replace(/<\s*strong\s*>([\s\S]*?)<\s*\/\s*strong\s*>/gi, '<strong>$1</strong>'); |
| formatted = formatted.replace(/<\s*em\s*>([\s\S]*?)<\s*\/\s*em\s*>/gi, '<em>$1</em>'); |
| formatted = formatted.replace(/<\s*br\s*\/?>/gi, '<br>'); |
| formatted = formatted.replace(/<\s*hr\s*\/?>/gi, '<hr>'); |
| |
| return formatted; |
| } |
|
|
| _formatInlineContent(text) { |
| if (!text) return ''; |
| let result = text; |
| |
| |
| result = result.replace(/<strong[^&]*>([\s\S]*?)<\/strong>/gi, '**$1**'); |
| result = result.replace(/<b[^&]*>([\s\S]*?)<\/b>/gi, '**$1**'); |
| result = result.replace(/<em[^&]*>([\s\S]*?)<\/em>/gi, '*$1*'); |
| result = result.replace(/<i[^&]*>([\s\S]*?)<\/i>/gi, '*$1*'); |
| result = result.replace(/<u[^&]*>([\s\S]*?)<\/u>/gi, '_$1_'); |
| result = result.replace(/<span[^&]*>([\s\S]*?)<\/span>/gi, '$1'); |
| |
| |
| let prev = ''; |
| let maxIter = 10; |
| while (prev !== result && maxIter-- > 0) { |
| prev = result; |
| result = result.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, '**$1**'); |
| result = result.replace(/<b[^>]*>([\s\S]*?)<\/b>/gi, '**$1**'); |
| result = result.replace(/<em[^>]*>([\s\S]*?)<\/em>/gi, '*$1*'); |
| result = result.replace(/<i[^>]*>([\s\S]*?)<\/i>/gi, '*$1*'); |
| result = result.replace(/<u[^>]*>([\s\S]*?)<\/u>/gi, '_$1_'); |
| result = result.replace(/<span[^>]*>([\s\S]*?)<\/span>/gi, '$1'); |
| } |
| |
| |
| const bolds = []; |
| const italics = []; |
| const underlines = []; |
| const boldMarker = '\u0000BOLD\u0000'; |
| const italicMarker = '\u0000ITALIC\u0000'; |
| const underlineMarker = '\u0000UNDERLINE\u0000'; |
| |
| result = result.replace(/\*\*([^*]+)\*\*/g, (_, content) => { |
| bolds.push(content); |
| return `${boldMarker}${bolds.length - 1}${boldMarker}`; |
| }); |
| |
| result = result.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, (_, content) => { |
| italics.push(content); |
| return `${italicMarker}${italics.length - 1}${italicMarker}`; |
| }); |
| |
| result = result.replace(/(?<![_\w])_([^_]+)_(?![_\w])/g, (match, content) => { |
| if (/__/.test(match)) return match; |
| underlines.push(content); |
| return `${underlineMarker}${underlines.length - 1}${underlineMarker}`; |
| }); |
| |
| |
| result = this.escapeHtmlDisplay(result); |
| |
| |
| bolds.forEach((content, i) => { |
| result = result.replace(new RegExp(`${boldMarker}${i}${boldMarker}`, 'g'), `<strong>${this.escapeHtmlDisplay(content)}</strong>`); |
| }); |
| |
| |
| italics.forEach((content, i) => { |
| result = result.replace(new RegExp(`${italicMarker}${i}${italicMarker}`, 'g'), `<em>${this.escapeHtmlDisplay(content)}</em>`); |
| }); |
| |
| |
| underlines.forEach((content, i) => { |
| result = result.replace(new RegExp(`${underlineMarker}${i}${underlineMarker}`, 'g'), `<u>${this.escapeHtmlDisplay(content)}</u>`); |
| }); |
| |
| return result; |
| } |
|
|
| _renderMath(math, displayMode = false) { |
| if (!math) return ''; |
| |
| try { |
| |
| const katexLib = typeof katex !== 'undefined' ? katex : (typeof window !== 'undefined' ? window.katex : null); |
| |
| if (katexLib && typeof katexLib.renderToString === 'function') { |
| const html = katexLib.renderToString(math, { |
| displayMode: displayMode, |
| throwOnError: false, |
| errorColor: '#cc0000', |
| strict: false, |
| trust: true, |
| macros: { |
| "\\R": "\\mathbb{R}", |
| "\\N": "\\mathbb{N}", |
| "\\Z": "\\mathbb{Z}", |
| "\\Q": "\\mathbb{Q}", |
| "\\C": "\\mathbb{C}" |
| } |
| }); |
| |
| if (displayMode) { |
| return `<div class="math-block">${html}</div>`; |
| } |
| return `<span class="math-inline">${html}</span>`; |
| } |
| } catch (e) { |
| console.warn('KaTeX rendering failed:', e); |
| } |
| |
| |
| let fallback = math |
| |
| .replace(/\\text\{([^}]+)\}/g, (_, text) => text.replace(/_/g, ' ')) |
| .replace(/\\textit\{([^}]+)\}/g, '$1') |
| .replace(/\\textbf\{([^}]+)\}/g, '$1') |
| .replace(/\\mathrm\{([^}]+)\}/g, '$1') |
| .replace(/\\mathit\{([^}]+)\}/g, '$1') |
| .replace(/\\mathbf\{([^}]+)\}/g, '$1') |
| |
| .replace(/\\left\s*/g, '') |
| .replace(/\\right\s*/g, '') |
| .replace(/\\big\s*/g, '') |
| .replace(/\\Big\s*/g, '') |
| .replace(/\\bigg\s*/g, '') |
| .replace(/\\Bigg\s*/g, '') |
| |
| .replace(/\\frac\{([^}]+)\}\{([^}]+)\}/g, '($1/$2)') |
| .replace(/\\dfrac\{([^}]+)\}\{([^}]+)\}/g, '($1/$2)') |
| .replace(/\\sqrt\{([^}]+)\}/g, '√($1)') |
| .replace(/\\sqrt(\d+)/g, '√$1') |
| .replace(/\\int_\{?([^}\s]+)\}?\^\{?([^}\s]+)\}?/g, '∫[$1→$2]') |
| .replace(/\\int/g, '∫') |
| .replace(/\\sum_\{?([^}\s]+)\}?\^\{?([^}\s]+)\}?/g, 'Σ[$1→$2]') |
| .replace(/\\sum/g, 'Σ') |
| .replace(/\\prod/g, '∏') |
| .replace(/\\partial/g, '∂') |
| .replace(/\\nabla/g, '∇') |
| .replace(/\\infty/g, '∞') |
| .replace(/\\alpha/g, 'α').replace(/\\beta/g, 'β').replace(/\\gamma/g, 'γ') |
| .replace(/\\delta/g, 'δ').replace(/\\Delta/g, 'Δ') |
| .replace(/\\epsilon/g, 'ε').replace(/\\varepsilon/g, 'ε') |
| .replace(/\\theta/g, 'θ').replace(/\\Theta/g, 'Θ') |
| .replace(/\\lambda/g, 'λ').replace(/\\Lambda/g, 'Λ') |
| .replace(/\\mu/g, 'μ').replace(/\\nu/g, 'ν') |
| .replace(/\\pi/g, 'π').replace(/\\Pi/g, 'Π') |
| .replace(/\\sigma/g, 'σ').replace(/\\Sigma/g, 'Σ') |
| .replace(/\\omega/g, 'ω').replace(/\\Omega/g, 'Ω') |
| .replace(/\\phi/g, 'φ').replace(/\\Phi/g, 'Φ') |
| .replace(/\\psi/g, 'ψ').replace(/\\Psi/g, 'Ψ') |
| .replace(/\\rho/g, 'ρ').replace(/\\tau/g, 'τ') |
| .replace(/\\eta/g, 'η').replace(/\\zeta/g, 'ζ') |
| .replace(/\\xi/g, 'ξ').replace(/\\Xi/g, 'Ξ') |
| .replace(/\\kappa/g, 'κ').replace(/\\iota/g, 'ι') |
| .replace(/\\chi/g, 'χ').replace(/\\upsilon/g, 'υ') |
| .replace(/\\times/g, '×').replace(/\\div/g, '÷').replace(/\\pm/g, '±') |
| .replace(/\\mp/g, '∓').replace(/\\cdot/g, '·') |
| .replace(/\\leq/g, '≤').replace(/\\geq/g, '≥').replace(/\\neq/g, '≠') |
| .replace(/\\le/g, '≤').replace(/\\ge/g, '≥').replace(/\\ne/g, '≠') |
| .replace(/\\approx/g, '≈').replace(/\\equiv/g, '≡') |
| .replace(/\\propto/g, '∝').replace(/\\sim/g, '∼') |
| .replace(/\\rightarrow/g, '→').replace(/\\leftarrow/g, '←') |
| .replace(/\\Rightarrow/g, '⇒').replace(/\\Leftarrow/g, '⇐') |
| .replace(/\\leftrightarrow/g, '↔').replace(/\\Leftrightarrow/g, '⇔') |
| .replace(/\\to/g, '→').replace(/\\gets/g, '←') |
| .replace(/\\forall/g, '∀').replace(/\\exists/g, '∃') |
| .replace(/\\in/g, '∈').replace(/\\notin/g, '∉') |
| .replace(/\\subset/g, '⊂').replace(/\\supset/g, '⊃') |
| .replace(/\\subseteq/g, '⊆').replace(/\\supseteq/g, '⊇') |
| .replace(/\\cup/g, '∪').replace(/\\cap/g, '∩') |
| .replace(/\\emptyset/g, '∅').replace(/\\varnothing/g, '∅') |
| .replace(/\\land/g, '∧').replace(/\\lor/g, '∨').replace(/\\neg/g, '¬') |
| .replace(/\\angle/g, '∠').replace(/\\perp/g, '⊥').replace(/\\parallel/g, '∥') |
| .replace(/\\ldots/g, '…').replace(/\\cdots/g, '⋯') |
| |
| .replace(/\\,/g, ' ').replace(/\\;/g, ' ').replace(/\\:/g, ' ') |
| .replace(/\\!/g, '').replace(/\\quad/g, ' ').replace(/\\qquad/g, ' ') |
| .replace(/\\ /g, ' ') |
| |
| .replace(/\\[a-zA-Z]+\{([^}]*)\}/g, '$1') |
| .replace(/\\[a-zA-Z]+/g, '') |
| .replace(/\{([^{}]*)\}/g, '$1'); |
| |
| |
| for (let i = 0; i < 5; i++) { |
| fallback = fallback.replace(/\^(\{[^{}]+\})/g, (_, exp) => { |
| const content = exp.slice(1, -1); |
| return content.split('').map(c => SUPERSCRIPTS[c] || SUPERSCRIPTS[c.toLowerCase()] || c).join(''); |
| }); |
| fallback = fallback.replace(/\^([0-9a-zA-Z+\-])/g, (_, c) => { |
| return SUPERSCRIPTS[c] || SUPERSCRIPTS[c.toLowerCase()] || `^${c}`; |
| }); |
| fallback = fallback.replace(/_(\{[^{}]+\})/g, (_, exp) => { |
| const content = exp.slice(1, -1); |
| return content.split('').map(c => SUBSCRIPTS[c] || SUBSCRIPTS[c.toLowerCase()] || c).join(''); |
| }); |
| fallback = fallback.replace(/_([0-9a-zA-Z])/g, (_, c) => { |
| return SUBSCRIPTS[c] || SUBSCRIPTS[c.toLowerCase()] || `_${c}`; |
| }); |
| } |
| |
| |
| fallback = fallback.replace(/[{}]/g, ''); |
| |
| if (displayMode) { |
| return `<div class="math-block math-fallback">${this.escapeHtmlDisplay(fallback)}</div>`; |
| } |
| return `<span class="math-inline math-fallback">${this.escapeHtmlDisplay(fallback)}</span>`; |
| } |
|
|
| _parseMarkdownTables(content, placeholders) { |
| if (!content) return content; |
| |
| const lines = content.split('\n'); |
| const result = []; |
| let i = 0; |
| |
| while (i < lines.length) { |
| const line = lines[i]; |
| |
| if (line.trim().startsWith('|') && line.trim().endsWith('|')) { |
| const nextLine = lines[i + 1]; |
| if (nextLine && /^\|[\s:|-]+\|$/.test(nextLine.trim())) { |
| const tableLines = [line]; |
| let j = i + 1; |
| |
| while (j < lines.length && lines[j].trim().startsWith('|')) { |
| tableLines.push(lines[j]); |
| j++; |
| } |
| |
| const tableHtml = this._convertTableToHtml(tableLines); |
| if (tableHtml) { |
| const placeholder = `__TABLE_BLOCK_${placeholders.length}__`; |
| placeholders.push(tableHtml); |
| result.push(placeholder); |
| i = j; |
| continue; |
| } |
| } |
| } |
| |
| result.push(line); |
| i++; |
| } |
| |
| return result.join('\n'); |
| } |
|
|
| _convertTableToHtml(lines) { |
| if (lines.length < 2) return null; |
| |
| const headerLine = lines[0].trim(); |
| const headerCells = headerLine.split('|').filter(c => c.trim()).map(c => c.trim()); |
| |
| const separatorLine = lines[1].trim(); |
| const aligns = separatorLine.split('|').filter(c => c.trim()).map(c => { |
| const cell = c.trim(); |
| if (cell.startsWith(':') && cell.endsWith(':')) return 'center'; |
| if (cell.endsWith(':')) return 'right'; |
| return 'left'; |
| }); |
| |
| let html = '<div class="table-wrapper"><table><thead><tr>'; |
| headerCells.forEach((h, i) => { |
| html += `<th style="text-align:${aligns[i] || 'left'}">${this._formatInlineContent(h)}</th>`; |
| }); |
| html += '</tr></thead><tbody>'; |
| |
| for (let i = 2; i < lines.length; i++) { |
| const cells = lines[i].split('|').filter(c => c !== '').map(c => c.trim()); |
| html += '<tr>'; |
| cells.forEach((c, j) => { |
| if (c !== undefined) { |
| html += `<td style="text-align:${aligns[j] || 'left'}">${this._formatInlineContent(c)}</td>`; |
| } |
| }); |
| html += '</tr>'; |
| } |
| |
| html += '</tbody></table></div>'; |
| return html; |
| } |
|
|
| _initCodeCopyButtons() { |
| this.messagesArea?.querySelectorAll('.code-copy-btn').forEach(btn => { |
| btn.addEventListener('click', async () => { |
| const codeBlock = btn.closest('.code-block'); |
| const code = codeBlock?.querySelector('code')?.textContent; |
| |
| if (code) { |
| try { |
| await navigator.clipboard.writeText(code); |
| btn.classList.add('copied'); |
| btn.querySelector('span').textContent = 'Copied!'; |
| setTimeout(() => { |
| btn.classList.remove('copied'); |
| btn.querySelector('span').textContent = 'Copy'; |
| }, 2000); |
| } catch (err) { |
| console.error('Failed to copy:', err); |
| } |
| } |
| }); |
| }); |
| } |
|
|
| |
| _selectUser(ip) { |
| this.currentUser = ip; |
| this.currentChat = null; |
| this.selectedUserName.textContent = this._maskIp(ip); |
| |
| |
| this._updateBreadcrumb(['Dashboard', `User: ${this._maskIp(ip)}`]); |
| |
| |
| const user = this.users.find(u => u.ip === ip); |
| if (this.chatCountBadge && this.chatCount && user) { |
| this.chatCount.textContent = user.chatCount || 0; |
| this.chatCountBadge.style.display = 'flex'; |
| } |
| |
| |
| if (user) { |
| this._renderUserDetailsPanel(user); |
| } |
| |
| |
| this.userList.querySelectorAll('.user-card').forEach(card => { |
| card.classList.toggle('active', card.dataset.ip === ip); |
| }); |
| |
| |
| this._loadUserChats(ip); |
| |
| |
| this.chatHeader.style.display = 'none'; |
| this.messagesArea.innerHTML = ` |
| <div class="empty-state"> |
| <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> |
| <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/> |
| </svg> |
| <p>Select a chat to view messages</p> |
| </div> |
| `; |
| |
| |
| if (window.innerWidth <= 768) { |
| this._closeSidebar(); |
| this._openChatsPanel(); |
| } |
| } |
| |
| _selectChat(chatId) { |
| this.currentChat = chatId; |
| |
| |
| const chat = this.chats.find(c => c.id === chatId); |
| const chatTitle = chat?.title || 'Chat'; |
| this._updateBreadcrumb(['Dashboard', `User: ${this._maskIp(this.currentUser)}`, chatTitle]); |
| |
| |
| this.chatsList.querySelectorAll('.chat-card').forEach(card => { |
| card.classList.toggle('active', card.dataset.id === chatId); |
| }); |
| |
| |
| this._loadChatMessages(chatId); |
| |
| |
| if (window.innerWidth <= 768) { |
| this._closeChatsPanel(); |
| } |
| } |
| |
| _updateBreadcrumb(items) { |
| if (!this.breadcrumb) return; |
| |
| const html = items.map((item, index) => { |
| const isLast = index === items.length - 1; |
| const isClickable = index < items.length - 1; |
| |
| let breadcrumbHtml = `<span class="breadcrumb-item ${isLast ? 'active' : ''}" ${isClickable ? 'data-level="' + index + '"' : ''}>${item}</span>`; |
| |
| if (!isLast) { |
| breadcrumbHtml += '<span class="breadcrumb-separator"></span>'; |
| } |
| |
| return breadcrumbHtml; |
| }).join(''); |
| |
| this.breadcrumb.innerHTML = html; |
| |
| |
| this.breadcrumb.querySelectorAll('.breadcrumb-item[data-level]').forEach(item => { |
| item.addEventListener('click', () => { |
| const level = parseInt(item.dataset.level); |
| this._navigateToBreadcrumbLevel(level); |
| }); |
| }); |
| } |
| |
| _navigateToBreadcrumbLevel(level) { |
| if (level === 0) { |
| |
| this.currentUser = null; |
| this.currentChat = null; |
| this.selectedUserName.textContent = '-'; |
| this._updateBreadcrumb(['Dashboard']); |
| |
| if (this.chatCountBadge) { |
| this.chatCountBadge.style.display = 'none'; |
| } |
| |
| |
| this.userList.querySelectorAll('.user-card').forEach(card => { |
| card.classList.remove('active'); |
| }); |
| |
| |
| this.chatsList.innerHTML = '<div class="empty-state"><p>Select a user to view chats</p></div>'; |
| this.messagesArea.innerHTML = '<div class="empty-state"><p>Select a chat to view messages</p></div>'; |
| this.chatHeader.style.display = 'none'; |
| } else if (level === 1 && this.currentUser) { |
| |
| this.currentChat = null; |
| this._updateBreadcrumb(['Dashboard', `User: ${this._maskIp(this.currentUser)}`]); |
| |
| |
| this.chatsList.querySelectorAll('.chat-card').forEach(card => { |
| card.classList.remove('active'); |
| }); |
| |
| |
| this.messagesArea.innerHTML = '<div class="empty-state"><p>Select a chat to view messages</p></div>'; |
| this.chatHeader.style.display = 'none'; |
| } |
| } |
|
|
| _filterUsers(query) { |
| const q = query.toLowerCase().trim(); |
| let visibleCount = 0; |
| |
| this.userList.querySelectorAll('.user-card').forEach(card => { |
| const ip = card.dataset.ip.toLowerCase(); |
| const deviceText = Array.from(card.querySelectorAll('.user-badge')) |
| .map(badge => badge.textContent.toLowerCase()).join(' '); |
| |
| const isVisible = ip.includes(q) || deviceText.includes(q); |
| card.style.display = isVisible ? '' : 'none'; |
| |
| if (isVisible) { |
| visibleCount++; |
| |
| this._highlightSearchText(card, q); |
| } |
| }); |
| |
| |
| if (this.searchResultsCount) { |
| if (q && visibleCount > 0) { |
| this.searchResultsCount.textContent = visibleCount; |
| this.searchResultsCount.style.display = 'block'; |
| } else { |
| this.searchResultsCount.style.display = 'none'; |
| } |
| } |
| |
| |
| if (q && visibleCount === 0) { |
| this.userList.innerHTML += '<div class="no-data">No users match your search</div>'; |
| } |
| } |
| |
| _highlightSearchText(element, query) { |
| if (!query) return; |
| |
| const textNodes = this._getTextNodes(element); |
| textNodes.forEach(node => { |
| const text = node.textContent; |
| const regex = new RegExp(`(${this.escapeRegex(query)})`, 'gi'); |
| if (regex.test(text)) { |
| const highlightedText = text.replace(regex, '<mark>$1</mark>'); |
| const wrapper = document.createElement('span'); |
| wrapper.innerHTML = highlightedText; |
| node.parentNode.replaceChild(wrapper, node); |
| } |
| }); |
| } |
| |
| _getTextNodes(element) { |
| const textNodes = []; |
| const walker = document.createTreeWalker( |
| element, |
| NodeFilter.SHOW_TEXT, |
| null, |
| false |
| ); |
| |
| let node; |
| while (node = walker.nextNode()) { |
| if (node.textContent.trim()) { |
| textNodes.push(node); |
| } |
| } |
| return textNodes; |
| } |
| |
| escapeRegex(string) { |
| return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| } |
|
|
| _switchTab(tab) { |
| |
| const currentTab = document.querySelector('.tab-btn.active'); |
| if (currentTab) { |
| currentTab.style.transform = 'scale(0.95)'; |
| setTimeout(() => { |
| currentTab.style.transform = ''; |
| }, 150); |
| } |
| |
| this.tabBtns.forEach(btn => { |
| const isActive = btn.dataset.tab === tab; |
| btn.classList.toggle('active', isActive); |
| |
| if (isActive) { |
| btn.style.transform = 'scale(1.05)'; |
| setTimeout(() => { |
| btn.style.transform = ''; |
| }, 150); |
| } |
| }); |
| |
| |
| const fadeOut = (element) => { |
| if (element) { |
| element.style.opacity = '0'; |
| element.style.transform = 'translateY(10px)'; |
| } |
| }; |
| |
| const fadeIn = (element) => { |
| if (element) { |
| setTimeout(() => { |
| element.style.display = ''; |
| element.style.opacity = '1'; |
| element.style.transform = 'translateY(0)'; |
| }, 150); |
| } |
| }; |
| |
| if (tab === 'users') { |
| fadeOut(this.statsTab); |
| setTimeout(() => { |
| this.statsTab.style.display = 'none'; |
| fadeIn(this.usersTab); |
| }, 150); |
| } else { |
| fadeOut(this.usersTab); |
| setTimeout(() => { |
| this.usersTab.style.display = 'none'; |
| fadeIn(this.statsTab); |
| }, 150); |
| } |
| } |
|
|
| _toggleSidebar() { |
| this.sidebar.classList.toggle('open'); |
| this.sidebarOverlay.classList.toggle('active'); |
| |
| document.body.style.overflow = this.sidebar.classList.contains('open') ? 'hidden' : ''; |
| } |
|
|
| _closeSidebar() { |
| this.sidebar.classList.remove('open'); |
| this.sidebarOverlay.classList.remove('active'); |
| document.body.style.overflow = ''; |
| } |
|
|
| _openChatsPanel() { |
| this.chatsPanel?.classList.add('open'); |
| this.chatsPanelOverlay?.classList.add('active'); |
| document.body.style.overflow = 'hidden'; |
| } |
|
|
| _closeChatsPanel() { |
| this.chatsPanel?.classList.remove('open'); |
| this.chatsPanelOverlay?.classList.remove('active'); |
| document.body.style.overflow = ''; |
| } |
|
|
| |
| async _refreshData() { |
| if (this.refreshBtn) { |
| this.refreshBtn.disabled = true; |
| this.refreshBtn.classList.add('spinning'); |
| } |
| |
| try { |
| |
| this._setLoadingState(true, 'refresh'); |
| |
| await this._loadData(); |
| |
| |
| if (this.currentUser) { |
| await this._loadUserChats(this.currentUser); |
| } |
| |
| |
| if (this.currentChat) { |
| await this._loadChatMessages(this.currentChat); |
| } |
| |
| this._showToast('Data refreshed successfully', 'success', 3000); |
| } catch (err) { |
| console.error('Refresh failed:', err); |
| this._showToast('Failed to refresh data', 'error'); |
| } finally { |
| this._setLoadingState(false); |
| if (this.refreshBtn) { |
| this.refreshBtn.disabled = false; |
| this.refreshBtn.classList.remove('spinning'); |
| this.refreshBtn.querySelector('span').textContent = 'Refresh'; |
| } |
| } |
| } |
| |
| |
| _initKeyboardShortcuts() { |
| document.addEventListener('keydown', (e) => { |
| |
| if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; |
| |
| switch (e.key) { |
| case 'Escape': |
| this._hideDialog(); |
| this._hideHelp(); |
| this._closeSidebar(); |
| this._closeChatsPanel(); |
| break; |
| case 'r': |
| case 'R': |
| if (e.ctrlKey || e.metaKey) { |
| e.preventDefault(); |
| this._refreshData(); |
| } |
| break; |
| case 'f': |
| case 'F': |
| if (e.ctrlKey || e.metaKey) { |
| e.preventDefault(); |
| this.userSearch?.focus(); |
| } |
| break; |
| case '1': |
| if (e.ctrlKey || e.metaKey) { |
| e.preventDefault(); |
| this._switchTab('users'); |
| } |
| break; |
| case '2': |
| if (e.ctrlKey || e.metaKey) { |
| e.preventDefault(); |
| this._switchTab('stats'); |
| } |
| break; |
| } |
| }); |
| } |
|
|
| |
| _toggleExportDropdown(e) { |
| e.stopPropagation(); |
| this.exportDropdown?.classList.toggle('open'); |
| } |
|
|
| async _exportJson() { |
| this.exportDropdown?.classList.remove('open'); |
| try { |
| const response = await fetch(`${API_BASE}/export?format=json`, { |
| headers: { 'Authorization': `Bearer ${this.token}` } |
| }); |
| |
| if (!response.ok) throw new Error('Export failed'); |
| |
| const blob = await response.blob(); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `rox-admin-export-${new Date().toISOString().slice(0, 10)}.json`; |
| a.click(); |
| URL.revokeObjectURL(url); |
| |
| this._showToast('JSON export downloaded', 'success'); |
| } catch (err) { |
| this._showToast('Export failed', 'error'); |
| } |
| } |
|
|
| _exportStatsPdf() { |
| this.exportDropdown?.classList.remove('open'); |
| const s = this.stats; |
| const now = new Date(); |
| const dateStr = now.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); |
| const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); |
| |
| |
| let modelUsageHtml = ''; |
| if (s.modelUsage && Object.keys(s.modelUsage).length > 0) { |
| const total = Object.values(s.modelUsage).reduce((a, b) => a + b, 0) || 1; |
| modelUsageHtml = Object.entries(s.modelUsage).map(([model, count]) => { |
| const percent = Math.round((count / total) * 100); |
| return `<div class="usage-row"><span class="usage-label">${this.escapeHtml(model)}</span><div class="usage-bar"><div class="usage-fill" style="width:${percent}%"></div></div><span class="usage-value">${count} (${percent}%)</span></div>`; |
| }).join(''); |
| } |
| |
| const pdfHtml = this._getPdfTemplate('Rox AI Admin Report', dateStr, timeStr, ` |
| <div class="stats-grid"> |
| <div class="stat-card"><div class="stat-value">${s.totalUsers || 0}</div><div class="stat-label">Total Users</div></div> |
| <div class="stat-card"><div class="stat-value">${s.totalChats || 0}</div><div class="stat-label">Total Chats</div></div> |
| <div class="stat-card"><div class="stat-value">${s.todayQueries || 0}</div><div class="stat-label">Today's Queries</div></div> |
| <div class="stat-card"><div class="stat-value">${s.totalMessages || 0}</div><div class="stat-label">Total Messages</div></div> |
| <div class="stat-card"><div class="stat-value">${s.activeSessions || 0}</div><div class="stat-label">Active Sessions</div></div> |
| <div class="stat-card"><div class="stat-value">${s.avgResponseTime || 0}ms</div><div class="stat-label">Avg Response Time</div></div> |
| </div> |
| ${modelUsageHtml ? `<h2>Model Usage</h2><div class="model-usage">${modelUsageHtml}</div>` : ''} |
| `); |
| |
| this._printPdf(pdfHtml); |
| this._showToast('Stats PDF ready - uncheck "Headers and footers" in print options', 'success'); |
| } |
|
|
| _exportUsersPdf() { |
| this.exportDropdown?.classList.remove('open'); |
| const now = new Date(); |
| const dateStr = now.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); |
| const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); |
| |
| const usersHtml = this.users.map(user => ` |
| <tr> |
| <td>${this.escapeHtml(this._maskIp(user.ip))}</td> |
| <td>${this.escapeHtml(user.device?.browser || '-')}</td> |
| <td>${this.escapeHtml(user.device?.os || '-')}</td> |
| <td>${user.chatCount || 0}</td> |
| <td><span class="status-badge ${user.isOnline ? 'online' : ''}">${user.isOnline ? 'Online' : 'Offline'}</span></td> |
| <td>${this.escapeHtml(this._formatTimeAgo(user.lastActivity))}</td> |
| </tr> |
| `).join(''); |
| |
| const pdfHtml = this._getPdfTemplate('Rox AI Users Report', dateStr, timeStr, ` |
| <p class="summary">Total Users: <strong>${this.users.length}</strong></p> |
| <table> |
| <thead> |
| <tr> |
| <th>IP Address</th> |
| <th>Browser</th> |
| <th>OS</th> |
| <th>Chats</th> |
| <th>Status</th> |
| <th>Last Active</th> |
| </tr> |
| </thead> |
| <tbody>${usersHtml}</tbody> |
| </table> |
| `); |
| |
| this._printPdf(pdfHtml); |
| this._showToast('Users PDF ready - uncheck "Headers and footers" in print options', 'success'); |
| } |
|
|
| _exportChatJson() { |
| if (!this.currentChat) { |
| this._showToast('No chat selected', 'error'); |
| return; |
| } |
| |
| const chat = this.chats.find(c => c.id === this.currentChat); |
| if (!chat) { |
| this._showToast('Chat not found', 'error'); |
| return; |
| } |
| |
| const data = { |
| chatId: this.currentChat, |
| title: chat.title, |
| exportedAt: new Date().toISOString(), |
| messageCount: chat.messageCount, |
| messages: this._currentMessages || [] |
| }; |
| |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `chat-${this.currentChat.slice(0, 8)}-${new Date().toISOString().slice(0, 10)}.json`; |
| a.click(); |
| URL.revokeObjectURL(url); |
| |
| this._showToast('Chat JSON downloaded', 'success'); |
| } |
|
|
| _exportChatPdf() { |
| if (!this.currentChat || !this._currentMessages?.length) { |
| this._showToast('No chat selected or no messages', 'error'); |
| return; |
| } |
| |
| const chat = this.chats.find(c => c.id === this.currentChat); |
| const now = new Date(); |
| const dateStr = now.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); |
| const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); |
| |
| |
| const messagesHtml = this._currentMessages.map(msg => { |
| const isUser = msg.role === 'user'; |
| const time = msg.timestamp ? new Date(msg.timestamp).toLocaleString() : ''; |
| const model = msg.model || ''; |
| const formattedContent = this._formatContentForPdf(msg.content || ''); |
| |
| return ` |
| <div class="message ${isUser ? 'user' : 'assistant'}"> |
| <div class="message-header"> |
| <span class="role-badge ${isUser ? 'user-badge' : 'ai-badge'}">${isUser ? 'USER' : 'ROX AI'}</span> |
| <span class="message-meta">${time}${model ? ` • ${this.escapeHtml(model)}` : ''}</span> |
| </div> |
| <div class="message-content">${formattedContent}</div> |
| </div> |
| `; |
| }).join(''); |
| |
| const pdfHtml = this._getPdfTemplate( |
| chat?.title || 'Chat Export', |
| dateStr, |
| timeStr, |
| `<p class="summary">${this._currentMessages.length} messages</p><div class="messages">${messagesHtml}</div>`, |
| true |
| ); |
| |
| this._printPdf(pdfHtml); |
| this._showToast('Chat PDF ready - uncheck "Headers and footers" in print options', 'success'); |
| } |
|
|
| |
| _formatContentForPdf(content) { |
| if (!content || typeof content !== 'string') return ''; |
| let formatted = content; |
| |
| |
| formatted = formatted.replace(/^🌐\s*Searching for.*?\.\.\.?\s*$/gm, ''); |
| formatted = formatted.replace(/^🌐\s*LIVE INTERNET SEARCH RESULTS:?\s*$/gm, ''); |
| formatted = formatted.replace(/^\s*\n+/g, '').replace(/\n{3,}/g, '\n\n'); |
| |
| |
| |
| |
| |
| |
| formatted = formatted.replace(/^(\d+)([A-Z][a-zA-Z])/gm, '$1. $2'); |
| |
| formatted = formatted.replace(/^(\d+)(\*\*[^*]+\*\*)/gm, '$1. $2'); |
| |
| formatted = formatted.replace(/^(\d+)([a-zA-Z])/gm, '$1. $2'); |
| |
| formatted = formatted.replace(/^(\d+)\.([^\s\d])/gm, '$1. $2'); |
| |
| formatted = formatted.replace(/^(\d+)\)\s*/gm, '$1. '); |
| |
| formatted = formatted.replace(/^(\d+)-\s*/gm, '$1. '); |
| |
| |
| |
| |
| formatted = formatted.replace(/(\s)(\d+)([A-Z][a-zA-Z])/g, '$1$2. $3'); |
| formatted = formatted.replace(/(\s)(\d+)([a-z])/g, '$1$2. $3'); |
| |
| |
| |
| formatted = formatted.replace(/([a-zA-Z])(\d+)([A-Z][a-zA-Z])/g, '$1\n$2. $3'); |
| |
| formatted = formatted.replace(/([a-z])(\d+)([a-z])/g, '$1 $2. $3'); |
| |
| |
| formatted = formatted.replace(/^-([A-Za-z])/gm, '- $1'); |
| formatted = formatted.replace(/^\*([A-Za-z])/gm, '* $1'); |
| formatted = formatted.replace(/^•([A-Za-z])/gm, '• $1'); |
| |
| |
| |
| formatted = formatted.replace(/^(\d+)\.([A-Za-z])/gm, '$1. $2'); |
| |
| formatted = formatted.replace(/^-([A-Za-z])/gm, '- $1'); |
| |
| |
| |
| formatted = formatted.replace(/\n(\d+)([A-Z])/g, '\n$1. $2'); |
| formatted = formatted.replace(/\n(\d+)([a-z])/g, '\n$1. $2'); |
| |
| |
| let prevStrip = ''; |
| let maxIterations = 15; |
| while (prevStrip !== formatted && maxIterations-- > 0) { |
| prevStrip = formatted; |
| formatted = formatted.replace(/<strong\b[^>]*>([\s\S]*?)<\/strong>/gi, '**$1**'); |
| formatted = formatted.replace(/<b\b[^>]*>([\s\S]*?)<\/b>/gi, '**$1**'); |
| formatted = formatted.replace(/<em\b[^>]*>([\s\S]*?)<\/em>/gi, '*$1*'); |
| formatted = formatted.replace(/<i\b[^>]*>([\s\S]*?)<\/i>/gi, '*$1*'); |
| formatted = formatted.replace(/<u\b[^>]*>([\s\S]*?)<\/u>/gi, '_$1_'); |
| formatted = formatted.replace(/<span\b[^>]*>([\s\S]*?)<\/span>/gi, '$1'); |
| formatted = formatted.replace(/<div\b[^>]*>([\s\S]*?)<\/div>/gi, '$1\n'); |
| formatted = formatted.replace(/<p\b[^>]*>([\s\S]*?)<\/p>/gi, '$1\n\n'); |
| formatted = formatted.replace(/<br\b[^>]*\/?>/gi, '\n'); |
| formatted = formatted.replace(/<h1\b[^>]*>([\s\S]*?)<\/h1>/gi, '# $1\n'); |
| formatted = formatted.replace(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi, '## $1\n'); |
| formatted = formatted.replace(/<h3\b[^>]*>([\s\S]*?)<\/h3>/gi, '### $1\n'); |
| formatted = formatted.replace(/<h4\b[^>]*>([\s\S]*?)<\/h4>/gi, '#### $1\n'); |
| formatted = formatted.replace(/<h5\b[^>]*>([\s\S]*?)<\/h5>/gi, '##### $1\n'); |
| formatted = formatted.replace(/<h6\b[^>]*>([\s\S]*?)<\/h6>/gi, '###### $1\n'); |
| formatted = formatted.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1'); |
| formatted = formatted.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n'); |
| formatted = formatted.replace(/<\/?(?:ul|ol)\b[^>]*>/gi, ''); |
| formatted = formatted.replace(/<blockquote\b[^>]*>([\s\S]*?)<\/blockquote>/gi, '> $1\n'); |
| formatted = formatted.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, '`$1`'); |
| formatted = formatted.replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, '```\n$1\n```'); |
| formatted = formatted.replace(/<hr\b[^>]*\/?>/gi, '\n---\n'); |
| } |
| |
| |
| formatted = formatted.replace(/&/g, '&'); |
| formatted = formatted.replace(/</g, '<'); |
| formatted = formatted.replace(/>/g, '>'); |
| formatted = formatted.replace(/"/g, '"'); |
| formatted = formatted.replace(/'/g, "'"); |
| formatted = formatted.replace(/'/g, "'"); |
| formatted = formatted.replace(/'/g, "'"); |
| formatted = formatted.replace(/ /g, ' '); |
| |
| |
| prevStrip = ''; |
| maxIterations = 5; |
| while (prevStrip !== formatted && maxIterations-- > 0) { |
| prevStrip = formatted; |
| formatted = formatted.replace(/<strong\b[^>]*>([\s\S]*?)<\/strong>/gi, '**$1**'); |
| formatted = formatted.replace(/<b\b[^>]*>([\s\S]*?)<\/b>/gi, '**$1**'); |
| formatted = formatted.replace(/<em\b[^>]*>([\s\S]*?)<\/em>/gi, '*$1*'); |
| formatted = formatted.replace(/<i\b[^>]*>([\s\S]*?)<\/i>/gi, '*$1*'); |
| formatted = formatted.replace(/<span\b[^>]*>([\s\S]*?)<\/span>/gi, '$1'); |
| } |
| |
| |
| formatted = formatted.replace(/<[a-z][^>]*\/>/gi, ''); |
| |
| formatted = formatted.replace(/<\/?(?:font|center|marquee|blink|nobr|wbr|s|strike|del|ins|mark|small|big|sub|sup|abbr|acronym|cite|dfn|kbd|samp|var|tt)\b[^>]*>/gi, ''); |
| |
| |
| |
| |
| formatted = formatted.replace(/^(\d+)([A-Z][a-zA-Z])/gm, '$1. $2'); |
| formatted = formatted.replace(/^(\d+)(\*\*[^*]+\*\*)/gm, '$1. $2'); |
| formatted = formatted.replace(/^(\d+)([a-zA-Z])/gm, '$1. $2'); |
| formatted = formatted.replace(/^(\d+)\.([^\s\d])/gm, '$1. $2'); |
| formatted = formatted.replace(/^(\d+)\)\s*/gm, '$1. '); |
| formatted = formatted.replace(/^(\d+)-\s*/gm, '$1. '); |
| |
| formatted = formatted.replace(/(\s)(\d+)([A-Z][a-zA-Z])/g, '$1$2. $3'); |
| formatted = formatted.replace(/(\s)(\d+)([a-z])/g, '$1$2. $3'); |
| |
| formatted = formatted.replace(/([a-zA-Z])(\d+)([A-Z][a-zA-Z])/g, '$1\n$2. $3'); |
| formatted = formatted.replace(/([a-z])(\d+)([a-z])/g, '$1 $2. $3'); |
| |
| formatted = formatted.replace(/^-([A-Za-z])/gm, '- $1'); |
| formatted = formatted.replace(/^\*([A-Za-z])/gm, '* $1'); |
| formatted = formatted.replace(/^•([A-Za-z])/gm, '• $1'); |
| |
| formatted = formatted.replace(/\n(\d+)([A-Z])/g, '\n$1. $2'); |
| formatted = formatted.replace(/\n(\d+)([a-z])/g, '\n$1. $2'); |
| |
| formatted = formatted.replace(/\*\*(\d+)([A-Z][a-zA-Z])/g, '**$1. $2'); |
| formatted = formatted.replace(/\*\*(\d+)([a-z])/g, '**$1. $2'); |
| |
| |
| formatted = formatted.replace(/\n{3,}/g, '\n\n'); |
| formatted = formatted.replace(/ +/g, ' '); |
| |
| |
| const mathBlocks = []; |
| const inlineMathPdf = []; |
| |
| |
| formatted = formatted.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => { |
| const placeholder = `__PDF_MATH_BLOCK_${mathBlocks.length}__`; |
| mathBlocks.push(math.trim()); |
| return placeholder; |
| }); |
| formatted = formatted.replace(/\\\[([\s\S]*?)\\\]/g, (_, math) => { |
| const placeholder = `__PDF_MATH_BLOCK_${mathBlocks.length}__`; |
| mathBlocks.push(math.trim()); |
| return placeholder; |
| }); |
| |
| |
| formatted = formatted.replace(/(?<!\$)\$(?!\$)([^\$\n]+?)\$(?!\$)/g, (_, math) => { |
| const placeholder = `__PDF_INLINE_MATH_${inlineMathPdf.length}__`; |
| inlineMathPdf.push(math.trim()); |
| return placeholder; |
| }); |
| formatted = formatted.replace(/\\\((.+?)\\\)/gs, (_, math) => { |
| const placeholder = `__PDF_INLINE_MATH_${inlineMathPdf.length}__`; |
| inlineMathPdf.push(math.trim()); |
| return placeholder; |
| }); |
| |
| |
| const codeBlocks = []; |
| formatted = formatted.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { |
| const placeholder = `__PDF_CODE_BLOCK_${codeBlocks.length}__`; |
| const language = lang ? lang.toUpperCase() : 'CODE'; |
| codeBlocks.push(`<div class="code-block-wrapper"><div class="code-block-header">${this.escapeHtml(language)}</div><pre><code>${this.escapeHtml(code.trim())}</code></pre></div>`); |
| return placeholder; |
| }); |
| |
| |
| const inlineCodes = []; |
| formatted = formatted.replace(/`([^`]+)`/g, (_, code) => { |
| const placeholder = `__PDF_INLINE_CODE_${inlineCodes.length}__`; |
| inlineCodes.push(`<code style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-family:'Courier New',monospace;font-size:13px;color:#7c3aed;">${this.escapeHtml(code)}</code>`); |
| return placeholder; |
| }); |
| |
| |
| formatted = formatted.replace(/^###### (.+)$/gm, (_, text) => `<h6 style="font-size:14px;font-weight:600;color:#4a5568;margin:16px 0 8px 0;">${this._formatInlineContentForPdf(text)}</h6>`); |
| formatted = formatted.replace(/^##### (.+)$/gm, (_, text) => `<h5 style="font-size:15px;font-weight:600;color:#2d3748;margin:18px 0 10px 0;">${this._formatInlineContentForPdf(text)}</h5>`); |
| formatted = formatted.replace(/^#### (.+)$/gm, (_, text) => `<h4 style="font-size:16px;font-weight:600;color:#2d3748;margin:20px 0 10px 0;">${this._formatInlineContentForPdf(text)}</h4>`); |
| formatted = formatted.replace(/^### (.+)$/gm, (_, text) => `<h3 style="font-size:18px;font-weight:600;color:#667eea;margin:22px 0 12px 0;padding-left:12px;border-left:3px solid #667eea;">${this._formatInlineContentForPdf(text)}</h3>`); |
| formatted = formatted.replace(/^## (.+)$/gm, (_, text) => `<h2 style="font-size:20px;font-weight:600;color:#1a202c;margin:24px 0 14px 0;padding-bottom:8px;border-bottom:1px solid #e2e8f0;">${this._formatInlineContentForPdf(text)}</h2>`); |
| formatted = formatted.replace(/^# (.+)$/gm, (_, text) => `<h1 style="font-size:24px;font-weight:700;color:#667eea;margin:28px 0 16px 0;padding-bottom:10px;border-bottom:2px solid #667eea;">${this._formatInlineContentForPdf(text)}</h1>`); |
| |
| |
| formatted = formatted.replace(/^(\d+)\.\s*(.+)$/gm, (_, num, text) => `<div style="display:flex;align-items:flex-start;margin:12px 0;gap:12px;"><span style="min-width:28px;height:28px;background:linear-gradient(135deg, #667eea 0%, #764ba2 100%);color:white;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;flex-shrink:0;">${num}</span><span style="flex:1;line-height:1.7;padding-top:4px;">${this._formatInlineContentForPdf(text.trim())}</span></div>`); |
| |
| |
| formatted = formatted.replace(/^[-*•]\s*(.+)$/gm, (_, text) => `<div style="display:flex;align-items:flex-start;margin:10px 0;padding-left:4px;gap:12px;"><span style="color:#667eea;font-size:20px;line-height:1;flex-shrink:0;">•</span><span style="flex:1;line-height:1.7;">${this._formatInlineContentForPdf(text.trim())}</span></div>`); |
| |
| |
| formatted = formatted.replace(/^> (.+)$/gm, (_, text) => `<blockquote style="margin:16px 0;padding:16px 20px;border-left:4px solid #667eea;background:linear-gradient(90deg, rgba(102, 126, 234, 0.08) 0%, transparent 100%);border-radius:0 8px 8px 0;font-style:italic;color:#4a5568;">${this._formatInlineContentForPdf(text)}</blockquote>`); |
| |
| |
| formatted = this._parseMarkdownTablesForPdf(formatted); |
| |
| |
| formatted = formatted.replace(/^---$/gm, '<hr style="border:none;height:2px;background:linear-gradient(90deg, transparent, #667eea, transparent);margin:24px 0;opacity:0.5;">'); |
| |
| |
| formatted = formatted.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => { |
| const safeUrl = this._sanitizeUrl(url); |
| if (!safeUrl) return this._formatInlineContentForPdf(text); |
| return `<a href="${this.escapeHtml(safeUrl)}" style="color:#667eea;text-decoration:none;border-bottom:1px solid #667eea;">${this._formatInlineContentForPdf(text)}</a>`; |
| }); |
| |
| |
| const lines = formatted.split('\n'); |
| const result = []; |
| let paragraphContent = []; |
| |
| for (const line of lines) { |
| const trimmed = line.trim(); |
| const isBlockElement = /^<(h[1-6]|div|blockquote|hr|pre|table|__PDF)/.test(trimmed) || |
| /<\/(h[1-6]|div|blockquote|pre|table)>$/.test(trimmed); |
| |
| if (isBlockElement || trimmed === '') { |
| if (paragraphContent.length > 0) { |
| const formattedParagraph = paragraphContent.map(p => this._formatInlineContentForPdf(p)).join('<br>'); |
| result.push('<p style="margin:0 0 12px 0;line-height:1.7;color:#2d3748;">' + formattedParagraph + '</p>'); |
| paragraphContent = []; |
| } |
| if (trimmed !== '') result.push(line); |
| } else { |
| paragraphContent.push(trimmed); |
| } |
| } |
| |
| if (paragraphContent.length > 0) { |
| const formattedParagraph = paragraphContent.map(p => this._formatInlineContentForPdf(p)).join('<br>'); |
| result.push('<p style="margin:0 0 12px 0;line-height:1.7;color:#2d3748;">' + formattedParagraph + '</p>'); |
| } |
| |
| formatted = result.join('\n'); |
| |
| |
| inlineCodes.forEach((code, i) => { |
| formatted = formatted.replace(new RegExp(`__PDF_INLINE_CODE_${i}__`, 'g'), code); |
| }); |
| |
| |
| codeBlocks.forEach((block, i) => { |
| formatted = formatted.replace(new RegExp(`__PDF_CODE_BLOCK_${i}__`, 'g'), block); |
| }); |
| |
| |
| mathBlocks.forEach((math, i) => { |
| const rendered = this._renderMathForPdf(math, true); |
| formatted = formatted.replace(new RegExp(`__PDF_MATH_BLOCK_${i}__`, 'g'), rendered); |
| }); |
| |
| |
| inlineMathPdf.forEach((math, i) => { |
| const rendered = this._renderMathForPdf(math, false); |
| formatted = formatted.replace(new RegExp(`__PDF_INLINE_MATH_${i}__`, 'g'), rendered); |
| }); |
| |
| |
| formatted = formatted.replace(/<p[^>]*><br><\/p>/g, ''); |
| formatted = formatted.replace(/<p[^>]*>\s*<\/p>/g, ''); |
| |
| return formatted; |
| } |
|
|
| |
| _renderMathForPdf(math, displayMode = false) { |
| if (!math) return ''; |
| |
| |
| let rendered = math |
| |
| .replace(/\\text\{([^}]+)\}/g, (_, text) => text.replace(/_/g, ' ')) |
| .replace(/\\textit\{([^}]+)\}/g, '$1') |
| .replace(/\\textbf\{([^}]+)\}/g, '$1') |
| .replace(/\\mathrm\{([^}]+)\}/g, '$1') |
| .replace(/\\mathit\{([^}]+)\}/g, '$1') |
| .replace(/\\mathbf\{([^}]+)\}/g, '$1') |
| |
| .replace(/\\left\s*/g, '').replace(/\\right\s*/g, '') |
| .replace(/\\big\s*/g, '').replace(/\\Big\s*/g, '') |
| .replace(/\\bigg\s*/g, '').replace(/\\Bigg\s*/g, '') |
| |
| .replace(/\\frac\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g, '($1)/($2)') |
| .replace(/\\frac\{([^}]+)\}\{([^}]+)\}/g, '($1/$2)') |
| .replace(/\\dfrac\{([^}]+)\}\{([^}]+)\}/g, '($1/$2)') |
| .replace(/\\tfrac\{([^}]+)\}\{([^}]+)\}/g, '($1/$2)') |
| |
| .replace(/\\sqrt\[([^\]]+)\]\{([^}]+)\}/g, (_, n, x) => n === '3' ? `∛(${x})` : n === '4' ? `∜(${x})` : `${n}√(${x})`) |
| .replace(/\\sqrt\{([^}]+)\}/g, '√($1)') |
| .replace(/\\sqrt(\d+)/g, '√$1') |
| |
| .replace(/\\int_\{([^}]+)\}\^\{([^}]+)\}/g, '∫[$1→$2]') |
| .replace(/\\int/g, '∫').replace(/\\oint/g, '∮') |
| .replace(/\\sum_\{([^}]+)\}\^\{([^}]+)\}/g, 'Σ[$1→$2]') |
| .replace(/\\sum/g, 'Σ').replace(/\\prod/g, '∏') |
| .replace(/\\lim_\{([^}]+)\}/g, 'lim[$1]').replace(/\\lim/g, 'lim') |
| |
| .replace(/\\partial/g, '∂').replace(/\\nabla/g, '∇').replace(/\\infty/g, '∞') |
| |
| .replace(/\\alpha/g, 'α').replace(/\\beta/g, 'β').replace(/\\gamma/g, 'γ') |
| .replace(/\\delta/g, 'δ').replace(/\\epsilon/g, 'ε').replace(/\\varepsilon/g, 'ε') |
| .replace(/\\zeta/g, 'ζ').replace(/\\eta/g, 'η').replace(/\\theta/g, 'θ') |
| .replace(/\\iota/g, 'ι').replace(/\\kappa/g, 'κ').replace(/\\lambda/g, 'λ') |
| .replace(/\\mu/g, 'μ').replace(/\\nu/g, 'ν').replace(/\\xi/g, 'ξ') |
| .replace(/\\pi/g, 'π').replace(/\\rho/g, 'ρ').replace(/\\sigma/g, 'σ') |
| .replace(/\\tau/g, 'τ').replace(/\\upsilon/g, 'υ').replace(/\\phi/g, 'φ') |
| .replace(/\\chi/g, 'χ').replace(/\\psi/g, 'ψ').replace(/\\omega/g, 'ω') |
| |
| .replace(/\\Gamma/g, 'Γ').replace(/\\Delta/g, 'Δ').replace(/\\Theta/g, 'Θ') |
| .replace(/\\Lambda/g, 'Λ').replace(/\\Xi/g, 'Ξ').replace(/\\Pi/g, 'Π') |
| .replace(/\\Sigma/g, 'Σ').replace(/\\Phi/g, 'Φ').replace(/\\Psi/g, 'Ψ').replace(/\\Omega/g, 'Ω') |
| |
| .replace(/\\times/g, '×').replace(/\\div/g, '÷').replace(/\\pm/g, '±') |
| .replace(/\\mp/g, '∓').replace(/\\cdot/g, '·').replace(/\\ast/g, '∗') |
| .replace(/\\star/g, '⋆').replace(/\\circ/g, '∘') |
| |
| .replace(/\\leq/g, '≤').replace(/\\geq/g, '≥').replace(/\\neq/g, '≠') |
| .replace(/\\le/g, '≤').replace(/\\ge/g, '≥').replace(/\\ne/g, '≠') |
| .replace(/\\approx/g, '≈').replace(/\\equiv/g, '≡').replace(/\\sim/g, '∼') |
| .replace(/\\propto/g, '∝').replace(/\\ll/g, '≪').replace(/\\gg/g, '≫') |
| |
| .replace(/\\rightarrow/g, '→').replace(/\\leftarrow/g, '←') |
| .replace(/\\Rightarrow/g, '⇒').replace(/\\Leftarrow/g, '⇐') |
| .replace(/\\leftrightarrow/g, '↔').replace(/\\Leftrightarrow/g, '⇔') |
| .replace(/\\to/g, '→').replace(/\\gets/g, '←').replace(/\\mapsto/g, '↦') |
| |
| .replace(/\\forall/g, '∀').replace(/\\exists/g, '∃') |
| .replace(/\\in/g, '∈').replace(/\\notin/g, '∉') |
| .replace(/\\subset/g, '⊂').replace(/\\supset/g, '⊃') |
| .replace(/\\subseteq/g, '⊆').replace(/\\supseteq/g, '⊇') |
| .replace(/\\cup/g, '∪').replace(/\\cap/g, '∩') |
| .replace(/\\emptyset/g, '∅').replace(/\\varnothing/g, '∅') |
| |
| .replace(/\\land/g, '∧').replace(/\\lor/g, '∨').replace(/\\lnot/g, '¬').replace(/\\neg/g, '¬') |
| |
| .replace(/\\angle/g, '∠').replace(/\\triangle/g, '△') |
| .replace(/\\perp/g, '⊥').replace(/\\parallel/g, '∥') |
| .replace(/\\therefore/g, '∴').replace(/\\because/g, '∵') |
| .replace(/\\ldots/g, '…').replace(/\\cdots/g, '⋯') |
| .replace(/\\prime/g, '′').replace(/\\degree/g, '°') |
| |
| .replace(/\\,/g, ' ').replace(/\\;/g, ' ').replace(/\\:/g, ' ') |
| .replace(/\\!/g, '').replace(/\\quad/g, ' ').replace(/\\qquad/g, ' ') |
| .replace(/\\ /g, ' ') |
| |
| .replace(/\\[a-zA-Z]+\{([^}]*)\}/g, '$1') |
| .replace(/\\[a-zA-Z]+/g, '') |
| .replace(/\{([^{}]*)\}/g, '$1'); |
| |
| |
| for (let i = 0; i < 5; i++) { |
| rendered = rendered.replace(/\^(\{[^{}]+\})/g, (_, exp) => { |
| const content = exp.slice(1, -1); |
| return content.split('').map(c => SUPERSCRIPTS[c] || SUPERSCRIPTS[c.toLowerCase()] || c).join(''); |
| }); |
| rendered = rendered.replace(/\^([0-9a-zA-Z+\-])/g, (_, c) => { |
| return SUPERSCRIPTS[c] || SUPERSCRIPTS[c.toLowerCase()] || `^${c}`; |
| }); |
| rendered = rendered.replace(/_(\{[^{}]+\})/g, (_, exp) => { |
| const content = exp.slice(1, -1); |
| return content.split('').map(c => SUBSCRIPTS[c] || SUBSCRIPTS[c.toLowerCase()] || c).join(''); |
| }); |
| rendered = rendered.replace(/_([0-9a-zA-Z])/g, (_, c) => { |
| return SUBSCRIPTS[c] || SUBSCRIPTS[c.toLowerCase()] || `_${c}`; |
| }); |
| } |
| |
| |
| rendered = rendered.replace(/[{}]/g, ''); |
| |
| if (displayMode) { |
| return `<div style="margin:20px 0;padding:16px 20px;background:linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);border-radius:8px;border-left:4px solid #667eea;text-align:center;font-family:'Times New Roman',Georgia,serif;font-size:16px;font-style:italic;color:#2d3748;">${this.escapeHtml(rendered)}</div>`; |
| } |
| return `<span style="padding:2px 6px;background:rgba(102, 126, 234, 0.1);border-radius:4px;font-family:'Times New Roman',Georgia,serif;font-style:italic;">${this.escapeHtml(rendered)}</span>`; |
| } |
|
|
| _parseMarkdownTablesForPdf(content) { |
| if (!content) return content; |
| const lines = content.split('\n'); |
| const result = []; |
| let i = 0; |
| |
| while (i < lines.length) { |
| const line = lines[i]; |
| if (line.trim().startsWith('|') && line.trim().endsWith('|')) { |
| const nextLine = lines[i + 1]; |
| if (nextLine && /^\|[\s:|-]+\|$/.test(nextLine.trim())) { |
| const tableLines = [line]; |
| let j = i + 1; |
| while (j < lines.length && lines[j].trim().startsWith('|')) { |
| tableLines.push(lines[j]); |
| j++; |
| } |
| result.push(this._convertTableToHtmlForPdf(tableLines)); |
| i = j; |
| continue; |
| } |
| } |
| result.push(line); |
| i++; |
| } |
| return result.join('\n'); |
| } |
|
|
| _convertTableToHtmlForPdf(lines) { |
| if (lines.length < 2) return lines.join('\n'); |
| |
| const sep = lines[1]; |
| const aligns = sep.split('|').filter(c => c.trim()).map(c => { |
| const t = c.trim(); |
| if (t.startsWith(':') && t.endsWith(':')) return 'center'; |
| if (t.endsWith(':')) return 'right'; |
| return 'left'; |
| }); |
| |
| const headerCells = lines[0].split('|').filter(c => c.trim()).map(c => c.trim()); |
| let html = '<table style="width:100%;border-collapse:collapse;margin:20px 0;border-radius:12px;overflow:hidden;box-shadow:0 4px 12px rgba(0,0,0,0.1);"><thead><tr style="background:linear-gradient(135deg, #667eea 0%, #764ba2 100%);">'; |
| headerCells.forEach((h, i) => { |
| html += `<th style="text-align:${aligns[i] || 'left'};padding:14px 16px;color:#ffffff;font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:0.5px;border:none;">${this._formatInlineContentForPdf(h)}</th>`; |
| }); |
| html += '</tr></thead><tbody>'; |
| for (let i = 2; i < lines.length; i++) { |
| const cells = lines[i].match(/\|([^|]*)/g)?.map(c => c.slice(1).trim()) || []; |
| const rowBg = (i - 2) % 2 === 0 ? '#ffffff' : '#f8fafc'; |
| html += `<tr style="background:${rowBg};">`; |
| cells.forEach((c, j) => { |
| if (c !== undefined) { |
| html += `<td style="text-align:${aligns[j] || 'left'};padding:14px 16px;border-bottom:1px solid #e2e8f0;color:#2d3748;font-size:14px;">${this._formatInlineContentForPdf(c)}</td>`; |
| } |
| }); |
| html += '</tr>'; |
| } |
| html += '</tbody></table>'; |
| return html; |
| } |
|
|
| |
| _formatInlineContentForPdf(text) { |
| if (!text) return ''; |
| let result = text; |
| |
| |
| result = result.replace(/<strong[^&]*>([\s\S]*?)<\/strong>/gi, '**$1**'); |
| result = result.replace(/<b[^&]*>([\s\S]*?)<\/b>/gi, '**$1**'); |
| result = result.replace(/<em[^&]*>([\s\S]*?)<\/em>/gi, '*$1*'); |
| result = result.replace(/<i[^&]*>([\s\S]*?)<\/i>/gi, '*$1*'); |
| result = result.replace(/<u[^&]*>([\s\S]*?)<\/u>/gi, '_$1_'); |
| result = result.replace(/<code[^&]*>([\s\S]*?)<\/code>/gi, '`$1`'); |
| |
| |
| result = result.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, '**$1**'); |
| result = result.replace(/<b[^>]*>([\s\S]*?)<\/b>/gi, '**$1**'); |
| result = result.replace(/<em[^>]*>([\s\S]*?)<\/em>/gi, '*$1*'); |
| result = result.replace(/<i[^>]*>([\s\S]*?)<\/i>/gi, '*$1*'); |
| result = result.replace(/<u[^>]*>([\s\S]*?)<\/u>/gi, '_$1_'); |
| result = result.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, '`$1`'); |
| result = result.replace(/<span[^>]*>([\s\S]*?)<\/span>/gi, '$1'); |
| |
| |
| const bolds = []; |
| const italics = []; |
| const underlines = []; |
| const codes = []; |
| const boldMarker = '\u0000BOLD\u0000'; |
| const italicMarker = '\u0000ITALIC\u0000'; |
| const underlineMarker = '\u0000UNDERLINE\u0000'; |
| const codeMarker = '\u0000CODE\u0000'; |
| |
| |
| result = result.replace(/\*\*([^*]+)\*\*/g, (_, content) => { |
| bolds.push(content); |
| return `${boldMarker}${bolds.length - 1}${boldMarker}`; |
| }); |
| |
| |
| result = result.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, (_, content) => { |
| italics.push(content); |
| return `${italicMarker}${italics.length - 1}${italicMarker}`; |
| }); |
| |
| |
| result = result.replace(/(?<![_\w])_([^_]+)_(?![_\w])/g, (match, content) => { |
| if (/__/.test(match)) return match; |
| underlines.push(content); |
| return `${underlineMarker}${underlines.length - 1}${underlineMarker}`; |
| }); |
| |
| |
| result = result.replace(/`([^`]+)`/g, (_, content) => { |
| codes.push(content); |
| return `${codeMarker}${codes.length - 1}${codeMarker}`; |
| }); |
| |
| |
| result = this.escapeHtmlDisplay(result); |
| |
| |
| bolds.forEach((content, i) => { |
| result = result.replace(new RegExp(`${boldMarker}${i}${boldMarker}`, 'g'), |
| `<strong style="font-weight:600;color:#1a202c;">${this.escapeHtmlDisplay(content)}</strong>`); |
| }); |
| |
| |
| italics.forEach((content, i) => { |
| result = result.replace(new RegExp(`${italicMarker}${i}${italicMarker}`, 'g'), |
| `<em style="font-style:italic;color:#4a5568;">${this.escapeHtmlDisplay(content)}</em>`); |
| }); |
| |
| |
| underlines.forEach((content, i) => { |
| result = result.replace(new RegExp(`${underlineMarker}${i}${underlineMarker}`, 'g'), |
| `<u style="text-decoration:underline;">${this.escapeHtmlDisplay(content)}</u>`); |
| }); |
| |
| |
| codes.forEach((content, i) => { |
| result = result.replace(new RegExp(`${codeMarker}${i}${codeMarker}`, 'g'), |
| `<code style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-family:'Fira Code',monospace;font-size:12px;color:#e53e3e;">${this.escapeHtmlDisplay(content)}</code>`); |
| }); |
| |
| return result; |
| } |
|
|
| |
| _getPdfTemplate(title, dateStr, timeStr, content, isChat = false) { |
| return `<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>${this.escapeHtml(title)}</title> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet"> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| @page { margin: 0.5in; size: A4; } |
| @media print { |
| * { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; } |
| body { padding: 0.5in; } |
| .code-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; } |
| } |
| body { |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| font-size: 14px; line-height: 1.7; color: #1a1a2e; background: #fff; |
| padding: 40px; max-width: 800px; margin: 0 auto; |
| } |
| .pdf-header { |
| display: flex; align-items: center; justify-content: space-between; |
| padding-bottom: 24px; margin-bottom: 32px; border-bottom: 2px solid #e5e7eb; |
| } |
| .pdf-brand { display: flex; align-items: center; gap: 16px; } |
| .pdf-logo { width: 64px; height: 64px; shape-rendering: geometricPrecision; image-rendering: -webkit-optimize-contrast; image-rendering: crisp-edges; } |
| .pdf-title { |
| font-size: 28px; font-weight: 700; |
| color: #667eea; |
| text-rendering: optimizeLegibility; |
| -webkit-font-smoothing: antialiased; |
| letter-spacing: -0.5px; |
| } |
| .pdf-meta { text-align: right; color: #6b7280; font-size: 12px; } |
| .pdf-meta-date { font-weight: 600; color: #374151; margin-bottom: 4px; } |
| .pdf-meta-label { |
| display: inline-block; padding: 4px 10px; |
| background: #667eea; |
| color: white; border-radius: 12px; font-size: 10px; font-weight: 600; |
| text-transform: uppercase; letter-spacing: 0.5px; margin-top: 8px; |
| } |
| .pdf-content { padding: 24px 0; } |
| .summary { font-size: 14px; color: #6b7280; margin-bottom: 20px; } |
| .summary strong { color: #667eea; } |
| |
| /* Stats Grid */ |
| .stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 32px; } |
| .stat-card { |
| background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); |
| border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px; text-align: center; |
| } |
| .stat-value { font-size: 28px; font-weight: 700; color: #667eea; } |
| .stat-label { font-size: 12px; color: #6b7280; margin-top: 4px; } |
| |
| /* Model Usage */ |
| .model-usage { margin-top: 16px; } |
| .usage-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } |
| .usage-label { width: 120px; font-size: 13px; color: #374151; font-weight: 500; } |
| .usage-bar { flex: 1; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; } |
| .usage-fill { height: 100%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 4px; } |
| .usage-value { width: 80px; font-size: 12px; color: #6b7280; text-align: right; } |
| |
| /* Tables */ |
| table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 13px; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); } |
| th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #e5e7eb; } |
| th { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-weight: 600; } |
| tr:nth-child(even) { background: #f9fafb; } |
| tr:last-child td { border-bottom: none; } |
| .status-badge { padding: 4px 8px; border-radius: 12px; font-size: 11px; font-weight: 500; } |
| .status-badge.online { background: #d1fae5; color: #059669; } |
| |
| /* Messages */ |
| .messages { display: flex; flex-direction: column; gap: 24px; } |
| .message { border-radius: 12px; overflow: hidden; } |
| .message.user { background: #f8fafc; border: 1px solid #e5e7eb; } |
| .message.assistant { background: linear-gradient(135deg, rgba(102,126,234,0.05) 0%, rgba(118,75,162,0.05) 100%); border: 1px solid rgba(102,126,234,0.2); } |
| .message-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid rgba(0,0,0,0.05); } |
| .role-badge { padding: 4px 12px; border-radius: 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; } |
| .user-badge { background: #374151; color: white; } |
| .ai-badge { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; } |
| .message-meta { font-size: 11px; color: #9ca3af; } |
| .message-content { padding: 16px; line-height: 1.7; color: #374151; } |
| .message-content p { margin: 0 0 12px; } |
| .message-content p:last-child { margin-bottom: 0; } |
| |
| /* Headings */ |
| h1 { font-size: 22px; font-weight: 700; color: #667eea; margin: 24px 0 12px; padding-bottom: 8px; border-bottom: 2px solid #667eea; } |
| h2 { font-size: 18px; font-weight: 600; color: #1f2937; margin: 20px 0 10px; padding-bottom: 6px; border-bottom: 1px solid #e5e7eb; } |
| h3 { font-size: 16px; font-weight: 600; color: #667eea; margin: 16px 0 8px; padding-left: 10px; border-left: 3px solid #667eea; } |
| h4, h5, h6 { font-size: 14px; font-weight: 600; color: #374151; margin: 14px 0 6px; } |
| |
| /* Lists */ |
| ul, ol { margin: 12px 0; padding-left: 24px; } |
| li { margin: 6px 0; color: #374151; } |
| li::marker { color: #667eea; } |
| |
| /* Code */ |
| .code-block { margin: 16px 0; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } |
| .code-block-wrapper { margin: 16px 0; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.1); } |
| .code-header, .code-block-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 8px 16px; font-size: 11px; font-weight: 600; letter-spacing: 0.5px; } |
| .code-block pre, .code-block-wrapper pre { margin: 0; background: #1e293b; } |
| .code-block code, .code-block-wrapper code { display: block; padding: 16px; font-family: 'Fira Code', monospace; font-size: 13px; line-height: 1.6; color: #e2e8f0; white-space: pre-wrap; word-wrap: break-word; } |
| .inline-code { background: #f3e8ff; color: #7c3aed; padding: 2px 6px; border-radius: 4px; font-family: 'Fira Code', monospace; font-size: 13px; } |
| |
| /* Blockquotes */ |
| blockquote { margin: 16px 0; padding: 12px 20px; border-left: 4px solid #667eea; background: #f8fafc; border-radius: 0 8px 8px 0; color: #4b5563; font-style: italic; } |
| |
| /* Links */ |
| a { color: #667eea; text-decoration: none; border-bottom: 1px solid #667eea; } |
| |
| /* HR */ |
| hr { border: none; height: 2px; background: linear-gradient(90deg, #667eea 0%, #764ba2 50%, #667eea 100%); margin: 24px 0; } |
| |
| strong { font-weight: 600; color: #1f2937; } |
| em { font-style: italic; color: #4b5563; } |
| |
| /* Footer */ |
| .pdf-footer { margin-top: 40px; padding-top: 20px; border-top: 2px solid #e5e7eb; text-align: center; color: #9ca3af; font-size: 12px; text-rendering: optimizeLegibility; } |
| .pdf-footer-brand { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; color: #6b7280; -webkit-font-smoothing: antialiased; } |
| .pdf-footer-logo { width: 28px; height: 28px; shape-rendering: geometricPrecision; image-rendering: crisp-edges; } |
| |
| @media print { |
| .message { page-break-inside: avoid; } |
| .code-block { page-break-inside: avoid; } |
| h1, h2, h3, h4, h5, h6 { page-break-after: avoid; } |
| } |
| </style> |
| </head> |
| <body> |
| <header class="pdf-header"> |
| <div class="pdf-brand"> |
| <svg class="pdf-logo" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision"> |
| <path d="M32 8 L56 20 L56 44 L32 56 L8 44 L8 20 Z" stroke="#667eea" stroke-width="4" fill="none" stroke-linejoin="round"/> |
| <circle cx="32" cy="32" r="12" fill="#764ba2"/> |
| </svg> |
| <span class="pdf-title">${this.escapeHtml(title)}</span> |
| </div> |
| <div class="pdf-meta"> |
| <div class="pdf-meta-date">${dateStr} at ${timeStr}</div> |
| <span class="pdf-meta-label">Admin Report</span> |
| </div> |
| </header> |
| <main class="pdf-content">${content}</main> |
| <footer class="pdf-footer"> |
| <div class="pdf-footer-brand"> |
| <svg class="pdf-footer-logo" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision"><path d="M32 8 L56 20 L56 44 L32 56 L8 44 L8 20 Z" stroke="#667eea" stroke-width="4" fill="none" stroke-linejoin="round"/><circle cx="32" cy="32" r="10" fill="#764ba2"/></svg> |
| Generated by Rox AI Admin Panel |
| </div> |
| </footer> |
| </body> |
| </html>`; |
| } |
|
|
| |
| _printPdf(pdfHtml) { |
| let existingFrame = document.getElementById('adminPdfFrame'); |
| if (existingFrame) existingFrame.remove(); |
| |
| const printFrame = document.createElement('iframe'); |
| printFrame.id = 'adminPdfFrame'; |
| printFrame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;'; |
| document.body.appendChild(printFrame); |
| |
| const frameWindow = printFrame.contentWindow; |
| if (!frameWindow) { |
| this._showToast('Failed to create print frame', 'error'); |
| return; |
| } |
| |
| const doc = frameWindow.document; |
| doc.open(); |
| doc.write(pdfHtml); |
| doc.close(); |
| |
| setTimeout(() => { |
| try { |
| frameWindow.focus(); |
| frameWindow.print(); |
| } catch (e) { |
| const printWindow = window.open('', '_blank', 'width=800,height=600'); |
| if (printWindow) { |
| printWindow.document.write(pdfHtml); |
| printWindow.document.close(); |
| printWindow.focus(); |
| setTimeout(() => printWindow.print(), 300); |
| } |
| } |
| }, 500); |
| } |
|
|
| |
| _currentMessages = []; |
|
|
| |
| _renderUserDetailsPanel(user) { |
| if (!this.userDetailsContent || !user) return; |
| |
| const isOnline = user.isOnline; |
| const firstSeenDate = user.firstSeen ? new Date(user.firstSeen).toLocaleDateString() : 'Unknown'; |
| const lastActiveDate = user.lastActivity ? new Date(user.lastActivity).toLocaleString() : 'Unknown'; |
| const engagementScore = user.engagementScore || 0; |
| const userType = user.userType || 'New'; |
| |
| |
| const ipParts = user.ip.split('.'); |
| const avatarText = ipParts.length >= 2 ? ipParts[0].slice(-1) + ipParts[1].slice(-1) : 'U'; |
| |
| |
| let activityChartHtml = ''; |
| if (user.activityByHour && user.activityByHour.some(v => v > 0)) { |
| const maxActivity = Math.max(...user.activityByHour, 1); |
| activityChartHtml = ` |
| <div class="user-activity-timeline"> |
| <div class="activity-hour-bar"> |
| ${user.activityByHour.map((val, hour) => { |
| const height = Math.max((val / maxActivity) * 100, 5); |
| const isPeak = val === maxActivity && val > 0; |
| return `<div class="activity-hour ${isPeak ? 'peak' : ''}" style="height: ${height}%" title="${hour}:00 - ${val} actions"></div>`; |
| }).join('')} |
| </div> |
| <div class="activity-hour-labels"> |
| <span>12AM</span> |
| <span>6AM</span> |
| <span>12PM</span> |
| <span>6PM</span> |
| <span>11PM</span> |
| </div> |
| </div> |
| `; |
| } |
| |
| |
| const tags = []; |
| if (user.sessionCount <= 1) tags.push({ label: 'New User', class: 'new' }); |
| else if (user.sessionCount > 5) tags.push({ label: 'Power User', class: 'power' }); |
| else tags.push({ label: 'Returning', class: 'returning' }); |
| |
| if (user.bounceStatus === 'Bounced') tags.push({ label: 'Bounced', class: 'dormant' }); |
| if (user.totalTokensUsed > 10000) tags.push({ label: 'Heavy Usage', class: 'power' }); |
| if (user.errorCount > 0) tags.push({ label: `${user.errorCount} Errors`, class: 'dormant' }); |
| |
| const tagsHtml = tags.map(t => `<span class="user-tag ${t.class}">${t.label}</span>`).join(''); |
| |
| const html = ` |
| <!-- User Profile Card --> |
| <div class="user-profile-card"> |
| <div class="user-profile-header"> |
| <div class="user-profile-avatar">${avatarText.toUpperCase()}</div> |
| <div class="user-profile-info"> |
| <div class="user-profile-ip">${this.escapeHtml(user.ip)}</div> |
| <div class="user-profile-status"> |
| <span class="status-dot ${isOnline ? 'online' : ''}"></span> |
| <span>${isOnline ? 'Online now' : 'Offline'}</span> |
| </div> |
| </div> |
| </div> |
| |
| <!-- User Stats Grid --> |
| <div class="user-stats-grid"> |
| <div class="user-stat-item"> |
| <div class="user-stat-value">${user.chatCount || 0}</div> |
| <div class="user-stat-label">Chats</div> |
| </div> |
| <div class="user-stat-item"> |
| <div class="user-stat-value">${user.totalMessages || 0}</div> |
| <div class="user-stat-label">Messages</div> |
| </div> |
| <div class="user-stat-item"> |
| <div class="user-stat-value">${user.sessionCount || 1}</div> |
| <div class="user-stat-label">Sessions</div> |
| </div> |
| <div class="user-stat-item"> |
| <div class="user-stat-value">${engagementScore}%</div> |
| <div class="user-stat-label">Engagement</div> |
| </div> |
| </div> |
| |
| <!-- User Tags --> |
| <div class="user-tags">${tagsHtml}</div> |
| </div> |
| |
| <!-- Device Info Section --> |
| <div class="user-info-section"> |
| <div class="user-info-section-title"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/> |
| </svg> |
| Device Info |
| </div> |
| <div class="user-info-row"> |
| <span class="user-info-label">Browser</span> |
| <span class="user-info-value">${this.escapeHtml(user.device?.fullBrowser || user.device?.browser || 'Unknown')}</span> |
| </div> |
| <div class="user-info-row"> |
| <span class="user-info-label">OS</span> |
| <span class="user-info-value">${this.escapeHtml(user.device?.fullOs || user.device?.os || 'Unknown')}</span> |
| </div> |
| <div class="user-info-row"> |
| <span class="user-info-label">Device Type</span> |
| <span class="user-info-value">${this.escapeHtml(user.device?.device || 'Unknown')}</span> |
| </div> |
| ${user.screenResolution ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Screen</span> |
| <span class="user-info-value">${this.escapeHtml(user.screenResolution)}</span> |
| </div> |
| ` : ''} |
| ${user.colorScheme ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Theme</span> |
| <span class="user-info-value">${user.colorScheme === 'dark' ? '🌙 Dark' : '☀️ Light'}</span> |
| </div> |
| ` : ''} |
| ${user.connectionType ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Connection</span> |
| <span class="user-info-value">${this.escapeHtml(user.connectionType)}</span> |
| </div> |
| ` : ''} |
| </div> |
| |
| <!-- Location Info Section --> |
| <div class="user-info-section"> |
| <div class="user-info-section-title"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/> |
| </svg> |
| Location & Source |
| </div> |
| ${user.country ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Country</span> |
| <span class="user-info-value">${this.escapeHtml(user.country)}</span> |
| </div> |
| ` : ''} |
| ${user.city ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">City</span> |
| <span class="user-info-value">${this.escapeHtml(user.city)}</span> |
| </div> |
| ` : ''} |
| ${user.language && user.language !== 'Unknown' ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Language</span> |
| <span class="user-info-value">${this.escapeHtml(user.language)}</span> |
| </div> |
| ` : ''} |
| ${user.referer && user.referer !== 'Direct' ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Source</span> |
| <span class="user-info-value">${this.escapeHtml(user.referer)}</span> |
| </div> |
| ` : ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Source</span> |
| <span class="user-info-value">Direct</span> |
| </div> |
| `} |
| </div> |
| |
| <!-- Activity Info Section --> |
| <div class="user-info-section"> |
| <div class="user-info-section-title"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/> |
| </svg> |
| Activity |
| </div> |
| <div class="user-info-row"> |
| <span class="user-info-label">First Seen</span> |
| <span class="user-info-value">${firstSeenDate}</span> |
| </div> |
| <div class="user-info-row"> |
| <span class="user-info-label">Last Active</span> |
| <span class="user-info-value">${lastActiveDate}</span> |
| </div> |
| ${user.visits ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Visits</span> |
| <span class="user-info-value">${user.visits}</span> |
| </div> |
| ` : ''} |
| ${user.pageViews ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Page Views</span> |
| <span class="user-info-value">${user.pageViews}</span> |
| </div> |
| ` : ''} |
| ${user.totalSessionTime ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Total Time</span> |
| <span class="user-info-value">${user.totalSessionTime}m</span> |
| </div> |
| ` : ''} |
| ${user.peakActivityHour !== null && user.peakActivityHour !== undefined ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Peak Hour</span> |
| <span class="user-info-value highlight">${user.peakActivityHour}:00</span> |
| </div> |
| ` : ''} |
| ${activityChartHtml} |
| </div> |
| |
| <!-- Model Usage Section --> |
| ${user.favoriteModel || user.totalTokensUsed ? ` |
| <div class="user-info-section"> |
| <div class="user-info-section-title"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/> |
| </svg> |
| AI Usage |
| </div> |
| ${user.favoriteModel ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Favorite Model</span> |
| <span class="user-info-value highlight">${this.escapeHtml(user.favoriteModel)}</span> |
| </div> |
| ` : ''} |
| ${user.totalTokensUsed ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Tokens Used</span> |
| <span class="user-info-value">${this._formatNumber(user.totalTokensUsed)}</span> |
| </div> |
| ` : ''} |
| ${user.avgResponseTime ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Avg Response</span> |
| <span class="user-info-value">${user.avgResponseTime}ms</span> |
| </div> |
| ` : ''} |
| ${user.errorCount > 0 ? ` |
| <div class="user-info-row"> |
| <span class="user-info-label">Errors</span> |
| <span class="user-info-value error">${user.errorCount}</span> |
| </div> |
| ` : ''} |
| </div> |
| ` : ''} |
| |
| <!-- Privacy Section --> |
| ${user.doNotTrack ? ` |
| <div class="user-info-section"> |
| <div class="user-info-section-title"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/> |
| </svg> |
| Privacy |
| </div> |
| <div class="user-info-row"> |
| <span class="user-info-label">Do Not Track</span> |
| <span class="user-info-value warning">Enabled</span> |
| </div> |
| </div> |
| ` : ''} |
| `; |
| |
| this.userDetailsContent.innerHTML = html; |
| |
| |
| if (this.userDetailsPanel) { |
| this.userDetailsPanel.classList.add('active'); |
| } |
| } |
| |
| _hideUserDetails() { |
| if (this.userDetailsPanel) { |
| this.userDetailsPanel.classList.remove('active'); |
| } |
| if (this.userDetailsContent) { |
| this.userDetailsContent.innerHTML = ` |
| <div class="empty-state"> |
| <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> |
| <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/> |
| </svg> |
| <p>Select a user to view details</p> |
| </div> |
| `; |
| } |
| } |
|
|
| _confirmClearLogs() { |
| this._showDialog({ |
| title: 'Clear All Logs', |
| message: 'This will permanently delete all chat logs and user data. This action cannot be undone.', |
| type: 'danger', |
| onConfirm: () => this._clearLogs() |
| }); |
| } |
|
|
| async _clearLogs() { |
| try { |
| const res = await this._apiPost('/clear-logs', { confirm: true }); |
| if (res.success) { |
| this._showToast(`Cleared ${res.cleared} logs`, 'success'); |
| this._loadData(); |
| this.currentUser = null; |
| this.currentChat = null; |
| this.selectedUserName.textContent = '-'; |
| this.chatsList.innerHTML = '<div class="empty-state"><p>Select a user to view chats</p></div>'; |
| this.messagesArea.innerHTML = '<div class="empty-state"><p>Select a chat to view messages</p></div>'; |
| this.chatHeader.style.display = 'none'; |
| } else { |
| this._showToast(res.error || 'Clear failed', 'error'); |
| } |
| } catch (err) { |
| this._showToast('Clear failed', 'error'); |
| } |
| } |
|
|
| |
| _showDialog({ title, message, type = 'warning', onConfirm }) { |
| this.dialogTitle.textContent = title; |
| this.dialogMessage.textContent = message; |
| |
| const icon = document.getElementById('dialogIcon'); |
| icon.className = `dialog-icon ${type}`; |
| icon.innerHTML = type === 'danger' |
| ? '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>' |
| : '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>'; |
| |
| this.dialogConfirm.onclick = () => { |
| this._hideDialog(); |
| if (onConfirm) onConfirm(); |
| }; |
| |
| this.dialogOverlay.style.display = 'flex'; |
| } |
|
|
| _hideDialog() { |
| this.dialogOverlay.style.display = 'none'; |
| } |
| |
| _showHelp() { |
| this.helpOverlay.style.display = 'flex'; |
| } |
| |
| _hideHelp() { |
| this.helpOverlay.style.display = 'none'; |
| } |
|
|
| _showToast(message, type = 'info', duration = 5000) { |
| const toast = document.createElement('div'); |
| toast.className = `toast ${type}`; |
| |
| const icons = { |
| success: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>', |
| error: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>', |
| warning: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>', |
| info: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>' |
| }; |
| |
| toast.innerHTML = ` |
| <span class="toast-icon">${icons[type] || icons.info}</span> |
| <span class="toast-message">${this.escapeHtml(message)}</span> |
| <button class="toast-close" aria-label="Close notification">×</button> |
| `; |
| |
| const closeBtn = toast.querySelector('.toast-close'); |
| closeBtn.onclick = () => this._removeToast(toast); |
| |
| this.toastContainer.appendChild(toast); |
| |
| |
| const timeoutId = setTimeout(() => this._removeToast(toast), duration); |
| |
| |
| toast.addEventListener('mouseenter', () => clearTimeout(timeoutId)); |
| toast.addEventListener('mouseleave', () => { |
| setTimeout(() => this._removeToast(toast), 1000); |
| }); |
| |
| |
| if (duration > 2000) { |
| const progressBar = document.createElement('div'); |
| progressBar.className = 'toast-progress'; |
| progressBar.style.cssText = ` |
| position: absolute; |
| bottom: 0; |
| left: 0; |
| height: 2px; |
| background: currentColor; |
| opacity: 0.3; |
| animation: toast-progress ${duration}ms linear; |
| `; |
| toast.appendChild(progressBar); |
| } |
| } |
| |
| _removeToast(toast) { |
| if (toast && toast.parentNode) { |
| toast.classList.add('removing'); |
| setTimeout(() => { |
| if (toast.parentNode) { |
| toast.parentNode.removeChild(toast); |
| } |
| }, 300); |
| } |
| } |
|
|
| |
| escapeHtml(str) { |
| if (!str || typeof str !== 'string') return ''; |
| return str.replace(/[&<>"']/g, c => HTML_ESCAPE_MAP[c] || c); |
| } |
|
|
| |
| escapeHtmlDisplay(text) { |
| if (typeof text !== 'string') return ''; |
| return text.replace(/</g, '<').replace(/>/g, '>'); |
| } |
|
|
| |
| _autoFormatMathExpressions(content) { |
| if (!content || typeof content !== 'string') return content; |
| |
| let formatted = content; |
| |
| |
| formatted = formatted.replace(/\b([a-zA-Z])\^(\{([^}]+)\}|([0-9]+))\b/g, (match, base, _, bracedExp, plainExp) => { |
| const exp = bracedExp || plainExp; |
| const converted = exp.split('').map(c => SUPERSCRIPTS[c] || SUPERSCRIPTS[c.toLowerCase()] || c).join(''); |
| const allConverted = exp.split('').every(c => SUPERSCRIPTS[c] || SUPERSCRIPTS[c.toLowerCase()]); |
| if (allConverted) return base + converted; |
| return match; |
| }); |
| |
| |
| formatted = formatted.replace(/\b([a-zA-Z])_(\{([^}]+)\}|([0-9]))\b/g, (match, base, _, bracedSub, plainSub) => { |
| if (match.includes('__')) return match; |
| const sub = bracedSub || plainSub; |
| const converted = sub.split('').map(c => SUBSCRIPTS[c] || SUBSCRIPTS[c.toLowerCase()] || c).join(''); |
| const allConverted = sub.split('').every(c => SUBSCRIPTS[c] || SUBSCRIPTS[c.toLowerCase()]); |
| if (allConverted) return base + converted; |
| return match; |
| }); |
| |
| |
| formatted = formatted.replace(/\b(\d+\.?\d*)[eE]([+-]?\d+)\b/g, (_, num, exp) => { |
| const expConverted = exp.split('').map(c => SUPERSCRIPTS[c] || c).join(''); |
| return `${num}×10${expConverted}`; |
| }); |
| |
| |
| const mathSymbols = [ |
| '≈', '≠', '≤', '≥', '±', '∓', '×', '÷', '·', |
| '→', '←', '↔', '⇒', '⇐', '⇔', '↑', '↓', |
| '∞', '∝', '∂', '∇', '∫', '∑', '∏', |
| '∈', '∉', '⊂', '⊃', '⊆', '⊇', '∪', '∩', '∅', |
| '∀', '∃', '∧', '∨', '¬', |
| 'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ', |
| 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'π', 'ρ', |
| 'σ', 'τ', 'υ', 'φ', 'χ', 'ψ', 'ω', |
| 'Γ', 'Δ', 'Θ', 'Λ', 'Ξ', 'Π', 'Σ', 'Φ', 'Ψ', 'Ω', |
| '√', '∛', '∜' |
| ]; |
| const symbolPattern = new RegExp(`([${mathSymbols.join('')}])`, 'g'); |
| formatted = formatted.replace(symbolPattern, '<span class="math-symbol">$1</span>'); |
| formatted = formatted.replace(/<span class="math-symbol"><span class="math-symbol">([^<]+)<\/span><\/span>/g, '<span class="math-symbol">$1</span>'); |
| |
| return formatted; |
| } |
|
|
| _sanitizeUrl(url) { |
| if (!url || typeof url !== 'string') return null; |
| const trimmed = url.trim(); |
| if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:')) return null; |
| if (trimmed.startsWith('http://') || trimmed.startsWith('https://') || trimmed.startsWith('/')) { |
| return trimmed; |
| } |
| return null; |
| } |
|
|
| _maskIp(ip) { |
| |
| if (!ip) return 'Unknown'; |
| return ip; |
| } |
|
|
| _formatTimeAgo(timestamp) { |
| if (!timestamp) return 'Unknown'; |
| const date = new Date(timestamp); |
| const now = new Date(); |
| const diff = now - date; |
| |
| const minutes = Math.floor(diff / 60000); |
| const hours = Math.floor(diff / 3600000); |
| const days = Math.floor(diff / 86400000); |
| |
| if (minutes < 1) return 'Just now'; |
| if (minutes < 60) return `${minutes}m ago`; |
| if (hours < 24) return `${hours}h ago`; |
| if (days < 7) return `${days}d ago`; |
| return date.toLocaleDateString(); |
| } |
|
|
| _formatDate(timestamp) { |
| if (!timestamp) return 'Unknown'; |
| return new Date(timestamp).toLocaleDateString('en-US', { |
| month: 'short', day: 'numeric', year: 'numeric' |
| }); |
| } |
|
|
| _formatTime(timestamp) { |
| if (!timestamp) return ''; |
| return new Date(timestamp).toLocaleTimeString('en-US', { |
| hour: 'numeric', minute: '2-digit', hour12: true |
| }); |
| } |
| } |
|
|
| |
| document.addEventListener('DOMContentLoaded', () => { |
| window.adminPanel = new AdminPanel(); |
| }); |
|
|