diff --git a/Cargo.toml b/Cargo.toml index f5a1afd..b582d23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ serde_json = { workspace = true } time = { workspace = true } tokio = { workspace = true, features = ["full"] } tokio-stream = { workspace = true } -tokio-tungstenite = { workspace = true, features = ["native-tls"] } +tokio-tungstenite = { workspace = true, features = ["native-tls", "connect"] } tower = { workspace = true } tower-http = { workspace = true, features = ["cors", "fs", "trace"] } tower-cookies = { workspace = true } diff --git a/src/ui_server/mod.rs b/src/ui_server/mod.rs index 4b36251..daa0d3b 100644 --- a/src/ui_server/mod.rs +++ b/src/ui_server/mod.rs @@ -15,7 +15,7 @@ use log::{debug, error, info}; use serde::Deserialize; use std::{fs, path::Path, path::PathBuf}; use tokio_tungstenite::{ - connect_async_tls_with_config, tungstenite::protocol::Message as TungsteniteMessage, + connect_async_tls_with_config, tungstenite, tungstenite::protocol::Message as TungsteniteMessage, }; use tower_http::services::ServeDir; @@ -135,7 +135,68 @@ pub async fn serve_minimal() -> impl IntoResponse { pub async fn serve_suite() -> impl IntoResponse { match fs::read_to_string("ui/suite/index.html") { - Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(html)), + Ok(raw_html) => { + let mut html = raw_html; + + // Core Apps + #[cfg(not(feature = "chat"))] { html = remove_section(&html, "chat"); } + #[cfg(not(feature = "mail"))] { html = remove_section(&html, "mail"); } + #[cfg(not(feature = "calendar"))] { html = remove_section(&html, "calendar"); } + #[cfg(not(feature = "drive"))] { html = remove_section(&html, "drive"); } + #[cfg(not(feature = "tasks"))] { html = remove_section(&html, "tasks"); } + #[cfg(not(feature = "meet"))] { html = remove_section(&html, "meet"); } + + // Documents + #[cfg(not(feature = "docs"))] { html = remove_section(&html, "docs"); } + #[cfg(not(feature = "sheet"))] { html = remove_section(&html, "sheet"); } + #[cfg(not(feature = "slides"))] { html = remove_section(&html, "slides"); } + #[cfg(not(feature = "paper"))] { html = remove_section(&html, "paper"); } + + // Research + #[cfg(not(feature = "research"))] { html = remove_section(&html, "research"); } + #[cfg(not(feature = "sources"))] { html = remove_section(&html, "sources"); } + #[cfg(not(feature = "learn"))] { html = remove_section(&html, "learn"); } + + // Analytics + #[cfg(not(feature = "analytics"))] { html = remove_section(&html, "analytics"); } + #[cfg(not(feature = "dashboards"))] { html = remove_section(&html, "dashboards"); } + #[cfg(not(feature = "monitoring"))] { html = remove_section(&html, "monitoring"); } + + // Business + #[cfg(not(feature = "people"))] { + html = remove_section(&html, "people"); + html = remove_section(&html, "crm"); + } + #[cfg(not(feature = "billing"))] { html = remove_section(&html, "billing"); } + #[cfg(not(feature = "products"))] { html = remove_section(&html, "products"); } + #[cfg(not(feature = "tickets"))] { html = remove_section(&html, "tickets"); } + + // Media + #[cfg(not(feature = "video"))] { html = remove_section(&html, "video"); } + #[cfg(not(feature = "player"))] { html = remove_section(&html, "player"); } + #[cfg(not(feature = "canvas"))] { html = remove_section(&html, "canvas"); } + + // Social & Project + #[cfg(not(feature = "social"))] { html = remove_section(&html, "social"); } + #[cfg(not(feature = "project"))] { html = remove_section(&html, "project"); } + #[cfg(not(feature = "goals"))] { html = remove_section(&html, "goals"); } + #[cfg(not(feature = "workspace"))] { html = remove_section(&html, "workspace"); } + + // Admin/Tools + #[cfg(not(feature = "admin"))] { + html = remove_section(&html, "admin"); + } + // Mapped security to tools feature + #[cfg(not(feature = "tools"))] { + html = remove_section(&html, "security"); + } + #[cfg(not(feature = "attendant"))] { html = remove_section(&html, "attendant"); } + #[cfg(not(feature = "designer"))] { html = remove_section(&html, "designer"); } + #[cfg(not(feature = "editor"))] { html = remove_section(&html, "editor"); } + #[cfg(not(feature = "settings"))] { html = remove_section(&html, "settings"); } + + (StatusCode::OK, [("content-type", "text/html")], Html(html)) + }, Err(e) => { error!("Failed to load suite UI: {e}"); ( @@ -147,6 +208,37 @@ pub async fn serve_suite() -> impl IntoResponse { } } +fn remove_section(html: &str, section: &str) -> String { + let start_marker = format!("", section); + let end_marker = format!("", section); + + let mut result = String::with_capacity(html.len()); + let mut current_pos = 0; + + // Process multiple occurrences of the section + while let Some(start_idx) = html[current_pos..].find(&start_marker) { + let abs_start = current_pos + start_idx; + // Append content up to the marker + result.push_str(&html[current_pos..abs_start]); + + // Find end marker + if let Some(end_idx) = html[abs_start..].find(&end_marker) { + // Skip past the end marker + current_pos = abs_start + end_idx + end_marker.len(); + } else { + // No end marker? This shouldn't happen with our script, + // but if it does, just skip the start marker and continue + // or consume everything? + // Safety: Skip start marker only + current_pos = abs_start + start_marker.len(); + } + } + + // Append remaining content + result.push_str(&html[current_pos..]); + result +} + async fn health(State(state): State) -> (StatusCode, axum::Json) { if state.health_check().await { ( @@ -369,7 +461,7 @@ async fn handle_task_progress_ws_proxy( let backend_result = connect_async_tls_with_config(&backend_url, None, false, Some(connector)).await; - let backend_socket = match backend_result { + let backend_socket: tokio_tungstenite::WebSocketStream> = match backend_result { Ok((socket, _)) => socket, Err(e) => { error!("Failed to connect to backend task-progress WebSocket: {e}"); @@ -386,38 +478,34 @@ async fn handle_task_progress_ws_proxy( while let Some(msg) = client_rx.next().await { match msg { Ok(AxumMessage::Text(text)) => { - if backend_tx + let res: Result<(), tungstenite::Error> = backend_tx .send(TungsteniteMessage::Text(text)) - .await - .is_err() - { + .await; + if res.is_err() { break; } } Ok(AxumMessage::Binary(data)) => { - if backend_tx + let res: Result<(), tungstenite::Error> = backend_tx .send(TungsteniteMessage::Binary(data)) - .await - .is_err() - { + .await; + if res.is_err() { break; } } Ok(AxumMessage::Ping(data)) => { - if backend_tx + let res: Result<(), tungstenite::Error> = backend_tx .send(TungsteniteMessage::Ping(data)) - .await - .is_err() - { + .await; + if res.is_err() { break; } } Ok(AxumMessage::Pong(data)) => { - if backend_tx + let res: Result<(), tungstenite::Error> = backend_tx .send(TungsteniteMessage::Pong(data)) - .await - .is_err() - { + .await; + if res.is_err() { break; } } @@ -427,7 +515,7 @@ async fn handle_task_progress_ws_proxy( }; let backend_to_client = async { - while let Some(msg) = backend_rx.next().await { + while let Some(msg) = backend_rx.next().await as Option> { match msg { Ok(TungsteniteMessage::Text(text)) => { // Log manifest_update messages for debugging @@ -505,7 +593,7 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu let backend_result = connect_async_tls_with_config(&backend_url, None, false, Some(connector)).await; - let backend_socket = match backend_result { + let backend_socket: tokio_tungstenite::WebSocketStream> = match backend_result { Ok((socket, _)) => socket, Err(e) => { error!("Failed to connect to backend WebSocket: {e}"); @@ -522,38 +610,34 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu while let Some(msg) = client_rx.next().await { match msg { Ok(AxumMessage::Text(text)) => { - if backend_tx + let res: Result<(), tungstenite::Error> = backend_tx .send(TungsteniteMessage::Text(text)) - .await - .is_err() - { + .await; + if res.is_err() { break; } } Ok(AxumMessage::Binary(data)) => { - if backend_tx + let res: Result<(), tungstenite::Error> = backend_tx .send(TungsteniteMessage::Binary(data)) - .await - .is_err() - { + .await; + if res.is_err() { break; } } Ok(AxumMessage::Ping(data)) => { - if backend_tx + let res: Result<(), tungstenite::Error> = backend_tx .send(TungsteniteMessage::Ping(data)) - .await - .is_err() - { + .await; + if res.is_err() { break; } } Ok(AxumMessage::Pong(data)) => { - if backend_tx + let res: Result<(), tungstenite::Error> = backend_tx .send(TungsteniteMessage::Pong(data)) - .await - .is_err() - { + .await; + if res.is_err() { break; } } diff --git a/ui/suite/index.html b/ui/suite/index.html index ff0298e..0fef571 100644 --- a/ui/suite/index.html +++ b/ui/suite/index.html @@ -1,3675 +1,1391 @@ - - - Chat - General Bots - - - - - - - + + + Chat - General Bots + + + - - - - - + + + + - + + + + + - - - - - + - - - + + + + + - - + + + - - + + - - - // Global error handler to catch HTMX DOM manipulation errors - window.addEventListener("error", function (event) { - // Catch insertBefore errors from HTMX swap operations - if ( - event.message && - event.message.includes("insertBefore") && - event.message.includes("null") - ) { - console.warn( - "HTMX DOM error caught and suppressed:", - event.message, - ); - event.preventDefault(); - return true; - } - }); + + - + // Global error handler to catch HTMX DOM manipulation errors + window.addEventListener("error", function (event) { + // Catch insertBefore errors from HTMX swap operations + if ( + event.message && + event.message.includes("insertBefore") && + event.message.includes("null") + ) { + console.warn( + "HTMX DOM error caught and suppressed:", + event.message, + ); + event.preventDefault(); + return true; + } + }); - - -
-
+ // Handle unhandled promise rejections from HTMX + window.addEventListener("unhandledrejection", function (event) { + if ( + event.reason && + event.reason.message && + event.reason.message.includes("insertBefore") + ) { + console.warn( + "HTMX promise rejection caught:", + event.reason.message, + ); + event.preventDefault(); + } + }); + + + + + +
+
+
+ + +
- - -
- -
- -
-
- Notifications - +
-
-
- 🔔 -

No notifications

-
+
+
+
+
+
+ + +
+ +
+ +
+
+ Notifications + +
+
+
+ 🔔 +

No notifications

-
+ + + + + + +
+ - - - - - - + +
+ +
+ +
+ +
- - -
- - - - -
- + + - -
- -
+ +
+ +
- - - - - - + + + + + + - - - + + + - - + + - // Settings panel handling - if (settingsBtn && settingsPanel) { - settingsBtn.addEventListener("click", (e) => { - e.stopPropagation(); - const isOpen = settingsPanel.classList.toggle("show"); - settingsBtn.setAttribute("aria-expanded", isOpen); - // Close apps dropdown - if (appsDropdown) appsDropdown.classList.remove("show"); - }); - - document.addEventListener("click", (e) => { - if ( - !settingsPanel.contains(e.target) && - !settingsBtn.contains(e.target) - ) { - settingsPanel.classList.remove("show"); - settingsBtn.setAttribute("aria-expanded", "false"); - } - }); - } - - // Theme selection handling - const themeOptions = document.querySelectorAll(".theme-option"); - const savedTheme = - localStorage.getItem("gb-theme") || "sentient"; - - // Apply saved theme - document.body.setAttribute("data-theme", savedTheme); - document - .querySelector(`.theme-option[data-theme="${savedTheme}"]`) - ?.classList.add("active"); - - themeOptions.forEach((option) => { - option.addEventListener("click", () => { - const theme = option.getAttribute("data-theme"); - document.body.setAttribute("data-theme", theme); - localStorage.setItem("gb-theme", theme); - themeOptions.forEach((o) => - o.classList.remove("active"), - ); - option.classList.add("active"); - - // Update theme-color meta tag - const themeColors = { - dark: "#3b82f6", - light: "#3b82f6", - purple: "#a855f7", - green: "#22c55e", - orange: "#f97316", - sentient: "#d4f505", - }; - const metaTheme = document.querySelector( - 'meta[name="theme-color"]', - ); - if (metaTheme) { - metaTheme.setAttribute( - "content", - themeColors[theme] || "#d4f505", - ); - } - }); - }); - - // List of sections that appear in header tabs - const headerTabSections = [ - "chat", - "paper", - "sheet", - "slides", - "mail", - "calendar", - "drive", - "tasks", - ]; - - // Update active states for navigation (single selection only) - function updateNavigationActive(section) { - // Remove all active states first - document - .querySelectorAll(".app-tab") - .forEach((t) => t.classList.remove("active")); - document - .querySelectorAll(".app-item") - .forEach((i) => i.classList.remove("active")); - appsBtn.classList.remove("active"); - - // Check if section is in header tabs - const isInHeaderTabs = headerTabSections.includes(section); - - // Activate the matching app-tab if in header - if (isInHeaderTabs) { - const headerTab = document.querySelector( - `.app-tab[data-section="${section}"]`, - ); - if (headerTab) { - headerTab.classList.add("active"); - } - } else { - // Section is NOT in header tabs, activate apps button - appsBtn.classList.add("active"); - } - - // Always mark the app-item in dropdown as active - const appItem = document.querySelector( - `.app-item[data-section="${section}"]`, - ); - if (appItem) { - appItem.classList.add("active"); - } - } - - // Handle app item clicks - update active state - document.querySelectorAll(".app-item").forEach((item) => { - item.addEventListener("click", function () { - const section = this.getAttribute("data-section"); - updateNavigationActive(section); - appsDropdown.classList.remove("show"); - appsBtn.setAttribute("aria-expanded", "false"); - }); - }); - - // Handle app tab clicks - document.querySelectorAll(".app-tab").forEach((tab) => { - tab.addEventListener("click", function () { - const section = this.getAttribute("data-section"); - updateNavigationActive(section); - }); - }); - - // Track currently loaded section to prevent duplicate loads - let currentLoadedSection = null; - let isLoadingSection = false; - let pendingLoadTimeout = null; - - // Handle hash navigation - function handleHashChange(fromHtmxSwap = false) { - const hash = window.location.hash.slice(1) || "chat"; - - // Skip if already loaded this section or currently loading - if (currentLoadedSection === hash || isLoadingSection) { - return; - } - - // If this was triggered by HTMX swap, just update tracking - if (fromHtmxSwap) { - currentLoadedSection = hash; - return; - } - - updateNavigationActive(hash); - - // Abort any pending load timeout - if (pendingLoadTimeout) { - clearTimeout(pendingLoadTimeout); - pendingLoadTimeout = null; - } - - // Abort any in-flight HTMX requests for main-content - const mainContent = document.getElementById("main-content"); - if (mainContent) { - try { - htmx.trigger(mainContent, "htmx:abort"); - } catch (e) { - // Ignore abort errors - } - } - - // Validate target exists before triggering HTMX load - if (!mainContent) { - console.warn( - "handleHashChange: #main-content not found, skipping load", - ); - return; - } - - // Check if main-content is in the DOM - if (!document.body.contains(mainContent)) { - console.warn( - "handleHashChange: #main-content not in DOM, skipping load", - ); - return; - } - - // Verify main-content has a valid parent (prevents insertBefore errors) - if (!mainContent.parentNode) { - console.warn( - "handleHashChange: #main-content has no parent, skipping load", - ); - return; - } - - // Debounce the load to prevent rapid double-requests - pendingLoadTimeout = setTimeout(() => { - // Re-check if section changed during debounce - const currentHash = - window.location.hash.slice(1) || "chat"; - if (currentLoadedSection === currentHash) { - return; - } - - // Trigger HTMX load - const appItem = document.querySelector( - `.app-item[data-section="${currentHash}"]`, - ); - if (appItem) { - const hxGet = appItem.getAttribute("hx-get"); - if (hxGet) { - try { - isLoadingSection = true; - currentLoadedSection = currentHash; - htmx.ajax("GET", hxGet, { - target: "#main-content", - swap: "innerHTML", - }); - } catch (e) { - console.warn( - "handleHashChange: HTMX ajax error:", - e, - ); - currentLoadedSection = null; - isLoadingSection = false; - } - } - } - }, 50); - } - - // Listen for HTMX swaps to track loaded sections and prevent duplicates - document.body.addEventListener("htmx:afterSwap", (event) => { - if ( - event.detail.target && - event.detail.target.id === "main-content" - ) { - const hash = window.location.hash.slice(1) || "chat"; - currentLoadedSection = hash; - isLoadingSection = false; - } - }); - - // Reset tracking on swap errors - document.body.addEventListener("htmx:swapError", (event) => { - if ( - event.detail.target && - event.detail.target.id === "main-content" - ) { - isLoadingSection = false; - } - }); - - // Also listen for response errors - document.body.addEventListener( - "htmx:responseError", - (event) => { - if ( - event.detail.target && - event.detail.target.id === "main-content" - ) { - isLoadingSection = false; - currentLoadedSection = null; - } - }, - ); - - // Load initial content based on hash or default to chat - window.addEventListener("hashchange", handleHashChange); - - // Initial load - wait for HTMX to be ready - function initialLoad() { - if (typeof htmx !== "undefined" && htmx.ajax) { - handleHashChange(); - } else { - setTimeout(initialLoad, 50); - } - } - - if (document.readyState === "complete") { - setTimeout(initialLoad, 50); - } else { - window.addEventListener("load", () => { - setTimeout(initialLoad, 50); - }); - } - - // ========================================================================== - // GBAlerts - Global Notification System - // ========================================================================== - window.GBAlerts = (function () { - const notifications = []; - const badge = document.getElementById("notificationsBadge"); - const list = document.getElementById("notificationsList"); - const btn = document.getElementById("notificationsBtn"); - const panel = document.getElementById("notificationsPanel"); - const clearBtn = document.getElementById( - "clearNotificationsBtn", - ); - - function updateBadge() { - if (badge) { - if (notifications.length > 0) { - badge.textContent = - notifications.length > 99 - ? "99+" - : notifications.length; - badge.style.display = "flex"; - } else { - badge.style.display = "none"; - } - } - } - - function renderList() { - if (!list) return; - if (notifications.length === 0) { - list.innerHTML = - '
🔔

No notifications

'; - } else { - list.innerHTML = notifications - .map( - (n, i) => ` -
-
${n.icon || "📢"}
-
-
${n.title}
-
${n.message || ""}
-
${n.time}
-
- ${n.action ? `` : ""} - -
- `, - ) - .join(""); - } - } - - function add(notification) { - notifications.unshift({ - ...notification, - time: new Date().toLocaleTimeString(), - }); - updateBadge(); - renderList(); - // Auto-open panel when new notification arrives - if (panel) { - panel.classList.add("show"); - if (btn) btn.setAttribute("aria-expanded", "true"); - } - } - - function dismiss(index) { - notifications.splice(index, 1); - updateBadge(); - renderList(); - } - - function clearAll() { - notifications.length = 0; - updateBadge(); - renderList(); - } - - function handleAction(index) { - const n = notifications[index]; - if (n && n.action) { - if (typeof n.action === "function") { - n.action(); - } else if (typeof n.action === "string") { - window.open(n.action, "_blank"); - } - } - dismiss(index); - } - - // Toggle panel - if (btn && panel) { - btn.addEventListener("click", (e) => { - e.stopPropagation(); - const isOpen = panel.classList.toggle("show"); - btn.setAttribute("aria-expanded", isOpen); - }); - - document.addEventListener("click", (e) => { - if ( - !panel.contains(e.target) && - !btn.contains(e.target) - ) { - panel.classList.remove("show"); - btn.setAttribute("aria-expanded", "false"); - } - }); - } - - if (clearBtn) { - clearBtn.addEventListener("click", clearAll); - } - - // Convenience methods for common notifications - return { - add, - dismiss, - clearAll, - handleAction, - taskCompleted: function (title, url) { - add({ - type: "success", - icon: "✅", - title: "Task Completed", - message: title, - action: url, - actionText: "Open App", - }); - }, - taskFailed: function (title, error) { - add({ - type: "error", - icon: "❌", - title: "Task Failed", - message: title + (error ? ": " + error : ""), - }); - }, - info: function (title, message) { - add({ - type: "info", - icon: "ℹ️", - title: title, - message: message, - }); - }, - warning: function (title, message) { - add({ - type: "warning", - icon: "⚠️", - title: title, - message: message, - }); - }, - connectionStatus: function (status, message) { - add({ - type: - status === "connected" - ? "success" - : status === "disconnected" - ? "error" - : "warning", - icon: - status === "connected" - ? "🟢" - : status === "disconnected" - ? "🔴" - : "🟡", - title: - "Connection " + - status.charAt(0).toUpperCase() + - status.slice(1), - message: message || "", - }); - }, - }; - })(); - - // Keyboard shortcuts - document.addEventListener("keydown", (e) => { - // Alt + number for quick app switching - if (e.altKey && !e.ctrlKey && !e.shiftKey) { - const num = parseInt(e.key); - if (num >= 1 && num <= 9) { - const items = - document.querySelectorAll(".app-item"); - if (items[num - 1]) { - items[num - 1].click(); - e.preventDefault(); - } - } - } - - // Alt + A to open apps menu - if (e.altKey && e.key.toLowerCase() === "a") { - appsBtn.click(); - e.preventDefault(); - } - }); - }); - - // User Profile Loading - (function () { - function updateUserUI(user) { - if (!user) return; - - const userName = document.getElementById("userName"); - const userEmail = document.getElementById("userEmail"); - const userAvatar = document.getElementById("userAvatar"); - const userAvatarLarge = - document.getElementById("userAvatarLarge"); - const authAction = document.getElementById("authAction"); - const authText = document.getElementById("authText"); - const authIcon = document.getElementById("authIcon"); - - const displayName = - user.display_name || - user.first_name || - user.username || - "User"; - const email = user.email || ""; - const initial = ( - displayName.charAt(0) || "U" - ).toUpperCase(); - - console.log("Updating user UI:", displayName, email); - - if (userName) userName.textContent = displayName; - if (userEmail) userEmail.textContent = email; - if (userAvatar) { - const avatarSpan = userAvatar.querySelector("span"); - if (avatarSpan) avatarSpan.textContent = initial; - } - if (userAvatarLarge) userAvatarLarge.textContent = initial; - - if (authAction) { - authAction.href = "#"; - authAction.onclick = function (e) { - e.preventDefault(); - fetch("/api/auth/logout", { - method: "POST", - }).finally(function () { - localStorage.removeItem("gb-access-token"); - localStorage.removeItem("gb-refresh-token"); - localStorage.removeItem("gb-user-data"); - sessionStorage.removeItem("gb-access-token"); - window.location.href = "/auth/login.html"; - }); - }; - authAction.style.color = "var(--error)"; - } - if (authText) authText.textContent = "Sign out"; - if (authIcon) { - authIcon.innerHTML = - ''; - } - } - - function loadUserProfile() { - var token = - localStorage.getItem("gb-access-token") || - sessionStorage.getItem("gb-access-token"); - if (!token) { - console.log("No auth token found"); - return; - } - - console.log( - "Loading user profile with token:", - token.substring(0, 10) + "...", - ); - - fetch("/api/auth/me", { - headers: { Authorization: "Bearer " + token }, - }) - .then(function (res) { - if (!res.ok) throw new Error("Not authenticated"); - return res.json(); - }) - .then(function (user) { - console.log("User profile loaded:", user); - updateUserUI(user); - localStorage.setItem( - "gb-user-data", - JSON.stringify(user), - ); - }) - .catch(function (err) { - console.log("Failed to load user profile:", err); - }); - } - - // Try to load cached user first - var cachedUser = localStorage.getItem("gb-user-data"); - if (cachedUser) { - try { - var user = JSON.parse(cachedUser); - if (user && user.email) { - updateUserUI(user); - } - } catch (e) {} - } - - // Always fetch fresh user data - if (document.readyState === "loading") { - document.addEventListener( - "DOMContentLoaded", - loadUserProfile, - ); - } else { - loadUserProfile(); - } - })(); - - - + \ No newline at end of file diff --git a/ui/suite/js/suite_app.js b/ui/suite/js/suite_app.js new file mode 100644 index 0000000..4f5bd34 --- /dev/null +++ b/ui/suite/js/suite_app.js @@ -0,0 +1,1288 @@ + // Simple initialization for HTMX app + // Note: Chat module is self-contained in chat.html + // ========================================== + // OMNIBOX (Search + Chat) Functionality + // ========================================== + const Omnibox = { + isActive: false, + isChatMode: false, + chatHistory: [], + selectedIndex: 0, + + init() { + this.omnibox = document.getElementById("omnibox"); + this.input = document.getElementById("omniboxInput"); + this.panel = document.getElementById("omniboxPanel"); + this.backdrop = document.getElementById("omniboxBackdrop"); + this.results = document.getElementById("omniboxResults"); + this.chat = document.getElementById("omniboxChat"); + this.chatMessages = document.getElementById( + "omniboxChatMessages", + ); + this.chatInput = + document.getElementById("omniboxChatInput"); + this.modeToggle = + document.getElementById("omniboxModeToggle"); + + this.bindEvents(); + }, + + bindEvents() { + // Input focus/blur + this.input.addEventListener("focus", () => this.open()); + this.backdrop.addEventListener("click", () => this.close()); + + // Input typing + this.input.addEventListener("input", (e) => + this.handleInput(e.target.value), + ); + + // Keyboard navigation + this.input.addEventListener("keydown", (e) => + this.handleKeydown(e), + ); + this.chatInput?.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + this.sendMessage(); + } + }); + + // Mode toggle + this.modeToggle.addEventListener("click", (e) => { + e.stopPropagation(); + this.toggleChatMode(); + }); + + // Action buttons + document + .querySelectorAll(".omnibox-action") + .forEach((btn) => { + btn.addEventListener("click", () => + this.handleAction(btn), + ); + }); + + // Send button + document + .getElementById("omniboxSendBtn") + ?.addEventListener("click", () => this.sendMessage()); + + // Back button + document + .getElementById("omniboxBackBtn") + ?.addEventListener("click", () => this.showResults()); + + // Expand button + document + .getElementById("omniboxExpandBtn") + ?.addEventListener("click", () => + this.expandToFullChat(), + ); + + // Global shortcut (Cmd+K / Ctrl+K) + document.addEventListener("keydown", (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + this.input.focus(); + this.open(); + } + if (e.key === "Escape" && this.isActive) { + this.close(); + } + }); + }, + + open() { + this.isActive = true; + this.omnibox.classList.add("active"); + this.updateActions(); + }, + + close() { + this.isActive = false; + this.omnibox.classList.remove("active"); + this.input.blur(); + }, + + handleInput(value) { + if (value.trim()) { + this.searchContent(value); + } else { + this.showDefaultActions(); + } + }, + + handleKeydown(e) { + const actions = document.querySelectorAll( + '.omnibox-action:not([style*="display: none"])', + ); + + if (e.key === "ArrowDown") { + e.preventDefault(); + this.selectedIndex = Math.min( + this.selectedIndex + 1, + actions.length - 1, + ); + this.updateSelection(actions); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + this.selectedIndex = Math.max( + this.selectedIndex - 1, + 0, + ); + this.updateSelection(actions); + } else if (e.key === "Enter") { + e.preventDefault(); + const selected = actions[this.selectedIndex]; + if (selected) { + this.handleAction(selected); + } else if (this.input.value.trim()) { + // Start chat with the query + this.startChatWithQuery(this.input.value); + } + } + }, + + updateSelection(actions) { + actions.forEach((a, i) => { + a.classList.toggle( + "selected", + i === this.selectedIndex, + ); + }); + }, + + updateActions() { + const currentApp = this.getCurrentApp(); + const actionsContainer = + document.getElementById("omniboxActions"); + + const contextActions = { + chat: [ + { + icon: "💬", + text: "New conversation", + action: "chat", + }, + { + icon: "📋", + text: "View history", + action: "navigate", + target: "chat", + }, + ], + mail: [ + { + icon: "✉️", + text: "Compose email", + action: "chat", + query: "Help me compose an email", + }, + { + icon: "📥", + text: "Check inbox", + action: "navigate", + target: "mail", + }, + ], + tasks: [ + { + icon: "✅", + text: "Create task", + action: "chat", + query: "Create a new task", + }, + { + icon: "📋", + text: "Show my tasks", + action: "navigate", + target: "tasks", + }, + ], + calendar: [ + { + icon: "📅", + text: "Schedule event", + action: "chat", + query: "Schedule a meeting", + }, + { + icon: "📆", + text: "View calendar", + action: "navigate", + target: "calendar", + }, + ], + default: [ + { + icon: "💬", + text: "Chat with Bot", + action: "chat", + }, + { + icon: "🔍", + text: "Search everywhere", + action: "search", + }, + ], + }; + + const actions = + contextActions[currentApp] || contextActions.default; + + // Add navigation shortcuts + const navActions = [ + { + icon: "📱", + text: "Open Chat", + action: "navigate", + target: "chat", + }, + { + icon: "✉️", + text: "Open Mail", + action: "navigate", + target: "mail", + }, + { + icon: "✓", + text: "Open Tasks", + action: "navigate", + target: "tasks", + }, + { + icon: "📅", + text: "Open Calendar", + action: "navigate", + target: "calendar", + }, + ]; + + actionsContainer.innerHTML = actions + .concat(navActions) + .map( + (a, i) => ` + + `, + ) + .join(""); + + // Rebind events + actionsContainer + .querySelectorAll(".omnibox-action") + .forEach((btn) => { + btn.addEventListener("click", () => + this.handleAction(btn), + ); + }); + + this.selectedIndex = 0; + }, + + getCurrentApp() { + const hash = window.location.hash.replace("#", ""); + return hash || "default"; + }, + + handleAction(btn) { + const action = btn.dataset.action; + const target = btn.dataset.target; + const query = btn.dataset.query; + + if (action === "chat") { + if (query) { + this.startChatWithQuery(query); + } else { + this.showChat(); + } + } else if (action === "navigate" && target) { + this.navigateTo(target); + } else if (action === "search") { + this.input.focus(); + } + }, + + navigateTo(target) { + this.close(); + const link = document.querySelector( + `a[data-section="${target}"]`, + ); + if (link) { + link.click(); + } + }, + + toggleChatMode() { + this.isChatMode = !this.isChatMode; + this.omnibox.classList.toggle("chat-mode", this.isChatMode); + + if (this.isChatMode) { + this.input.placeholder = "Ask me anything..."; + this.showChat(); + } else { + this.input.placeholder = "Search or ask anything..."; + this.showResults(); + } + }, + + showChat() { + this.results.style.display = "none"; + this.chat.style.display = "flex"; + this.isChatMode = true; + this.omnibox.classList.add("chat-mode"); + this.chatInput?.focus(); + }, + + showResults() { + this.chat.style.display = "none"; + this.results.style.display = "block"; + this.isChatMode = false; + this.omnibox.classList.remove("chat-mode"); + this.input.focus(); + }, + + showDefaultActions() { + document.getElementById( + "searchResultsSection", + ).style.display = "none"; + this.updateActions(); + }, + + searchContent(query) { + // Show search results section + const resultsSection = document.getElementById( + "searchResultsSection", + ); + const resultsList = + document.getElementById("searchResultsList"); + + resultsSection.style.display = "block"; + + // Update first action to be "Ask about: query" + const actionsContainer = + document.getElementById("omniboxActions"); + const firstAction = + actionsContainer.querySelector(".omnibox-action"); + if (firstAction) { + firstAction.dataset.action = "chat"; + firstAction.dataset.query = query; + firstAction.querySelector(".action-icon").textContent = + "💬"; + firstAction.querySelector(".action-text").textContent = + `Ask: "${query.substring(0, 30)}${query.length > 30 ? "..." : ""}"`; + } + + // Simple client-side search of navigation items + const searchResults = this.performSearch(query); + resultsList.innerHTML = + searchResults + .map( + (r) => ` + + `, + ) + .join("") || + '
No results found. Try asking the bot!
'; + + // Bind click events + resultsList + .querySelectorAll(".omnibox-result") + .forEach((btn) => { + btn.addEventListener("click", () => + this.navigateTo(btn.dataset.target), + ); + }); + }, + + performSearch(query) { + const q = query.toLowerCase(); + const items = [ + { + target: "chat", + icon: "💬", + title: "Chat", + description: "Chat with the bot", + }, + { + target: "mail", + icon: "✉️", + title: "Mail", + description: "Email inbox", + }, + { + target: "tasks", + icon: "✓", + title: "Tasks", + description: "Task management", + }, + { + target: "calendar", + icon: "📅", + title: "Calendar", + description: "Schedule and events", + }, + { + target: "drive", + icon: "📁", + title: "Drive", + description: "File storage", + }, + { + target: "paper", + icon: "📄", + title: "Documents", + description: "Document editor", + }, + { + target: "sheet", + icon: "📊", + title: "Sheet", + description: "Spreadsheets", + }, + { + target: "slides", + icon: "📽️", + title: "Slides", + description: "Presentations", + }, + { + target: "editor", + icon: "📝", + title: "Editor", + description: "Code & text editor", + }, + { + target: "designer", + icon: "🔷", + title: "Designer", + description: "Visual .bas editor", + }, + { + target: "meet", + icon: "📹", + title: "Meet", + description: "Video meetings", + }, + { + target: "research", + icon: "🔍", + title: "Research", + description: "Research assistant", + }, + { + target: "analytics", + icon: "📊", + title: "Analytics", + description: "Data analytics", + }, + { + target: "settings", + icon: "⚙️", + title: "Settings", + description: "App settings", + }, + ]; + + return items.filter( + (item) => + item.title.toLowerCase().includes(q) || + item.description.toLowerCase().includes(q), + ); + }, + + startChatWithQuery(query) { + this.showChat(); + this.input.value = ""; + setTimeout(() => { + this.addMessage(query, "user"); + this.sendToBot(query); + }, 100); + }, + + async sendMessage() { + const message = this.chatInput?.value.trim(); + if (!message) return; + + this.chatInput.value = ""; + this.addMessage(message, "user"); + await this.sendToBot(message); + }, + + async sendToBot(message) { + // Show typing indicator + const typingId = this.addTypingIndicator(); + + try { + // Call the bot API + const response = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message, + context: this.getCurrentApp(), + }), + }); + + this.removeTypingIndicator(typingId); + + if (response.ok) { + const data = await response.json(); + this.addMessage( + data.reply || + data.message || + "I received your message.", + "bot", + ); + + // Handle any actions the bot suggests + if (data.action) { + this.handleBotAction(data.action); + } + } else { + this.addMessage( + "Sorry, I encountered an error. Please try again.", + "bot", + ); + } + } catch (error) { + this.removeTypingIndicator(typingId); + // Fallback response when API is not available + this.addMessage( + this.getFallbackResponse(message), + "bot", + ); + } + }, + + getFallbackResponse(message) { + const msg = message.toLowerCase(); + if (msg.includes("help")) { + return "I can help you navigate the app, search for content, manage tasks, compose emails, and more. What would you like to do?"; + } else if (msg.includes("task") || msg.includes("todo")) { + return "Would you like me to open Tasks for you? You can create, view, and manage your tasks there."; + } else if (msg.includes("email") || msg.includes("mail")) { + return "I can help with email! Would you like to compose a new message or check your inbox?"; + } else if ( + msg.includes("calendar") || + msg.includes("meeting") || + msg.includes("schedule") + ) { + return "I can help with scheduling. Would you like to create an event or view your calendar?"; + } + return ( + "I understand you're asking about: \"" + + message + + '". How can I assist you further?' + ); + }, + + addMessage(text, sender) { + const msgDiv = document.createElement("div"); + msgDiv.className = `omnibox-message ${sender}`; + msgDiv.innerHTML = ` +
${sender === "user" ? "👤" : "🤖"}
+
${this.escapeHtml(text)}
+ `; + this.chatMessages.appendChild(msgDiv); + this.chatMessages.scrollTop = + this.chatMessages.scrollHeight; + + this.chatHistory.push({ role: sender, content: text }); + }, + + addTypingIndicator() { + const id = "typing-" + Date.now(); + const typingDiv = document.createElement("div"); + typingDiv.id = id; + typingDiv.className = "omnibox-message bot typing"; + typingDiv.innerHTML = ` +
🤖
+
+ +
+ `; + this.chatMessages.appendChild(typingDiv); + this.chatMessages.scrollTop = + this.chatMessages.scrollHeight; + return id; + }, + + removeTypingIndicator(id) { + const el = document.getElementById(id); + if (el) el.remove(); + }, + + handleBotAction(action) { + if (action.navigate) { + setTimeout( + () => this.navigateTo(action.navigate), + 1000, + ); + } + }, + + expandToFullChat() { + this.close(); + const chatLink = document.querySelector( + 'a[data-section="chat"]', + ); + if (chatLink) chatLink.click(); + }, + + escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + }, + }; + + // Initialize Omnibox when DOM is ready + document.addEventListener("DOMContentLoaded", () => { + Omnibox.init(); + console.log("🚀 Initializing General Bots with HTMX..."); + + // Hide loading overlay + setTimeout(() => { + const loadingOverlay = + document.getElementById("loadingOverlay"); + if (loadingOverlay) { + loadingOverlay.classList.add("hidden"); + } + }, 500); + + // Simple apps menu handling + const appsBtn = document.getElementById("appsButton"); + const appsDropdown = document.getElementById("appsDropdown"); + const settingsBtn = document.getElementById("settingsBtn"); + const settingsPanel = document.getElementById("settingsPanel"); + + if (appsBtn && appsDropdown) { + appsBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const isOpen = appsDropdown.classList.toggle("show"); + appsBtn.setAttribute("aria-expanded", isOpen); + // Close settings panel + if (settingsPanel) + settingsPanel.classList.remove("show"); + }); + + document.addEventListener("click", (e) => { + if ( + !appsDropdown.contains(e.target) && + !appsBtn.contains(e.target) + ) { + appsDropdown.classList.remove("show"); + appsBtn.setAttribute("aria-expanded", "false"); + } + }); + } + + // Settings panel handling + if (settingsBtn && settingsPanel) { + settingsBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const isOpen = settingsPanel.classList.toggle("show"); + settingsBtn.setAttribute("aria-expanded", isOpen); + // Close apps dropdown + if (appsDropdown) appsDropdown.classList.remove("show"); + }); + + document.addEventListener("click", (e) => { + if ( + !settingsPanel.contains(e.target) && + !settingsBtn.contains(e.target) + ) { + settingsPanel.classList.remove("show"); + settingsBtn.setAttribute("aria-expanded", "false"); + } + }); + } + + // Theme selection handling + const themeOptions = document.querySelectorAll(".theme-option"); + const savedTheme = + localStorage.getItem("gb-theme") || "sentient"; + + // Apply saved theme + document.body.setAttribute("data-theme", savedTheme); + document + .querySelector(`.theme-option[data-theme="${savedTheme}"]`) + ?.classList.add("active"); + + themeOptions.forEach((option) => { + option.addEventListener("click", () => { + const theme = option.getAttribute("data-theme"); + document.body.setAttribute("data-theme", theme); + localStorage.setItem("gb-theme", theme); + themeOptions.forEach((o) => + o.classList.remove("active"), + ); + option.classList.add("active"); + + // Update theme-color meta tag + const themeColors = { + dark: "#3b82f6", + light: "#3b82f6", + purple: "#a855f7", + green: "#22c55e", + orange: "#f97316", + sentient: "#d4f505", + }; + const metaTheme = document.querySelector( + 'meta[name="theme-color"]', + ); + if (metaTheme) { + metaTheme.setAttribute( + "content", + themeColors[theme] || "#d4f505", + ); + } + }); + }); + + // List of sections that appear in header tabs + const headerTabSections = [ + "chat", + "paper", + "sheet", + "slides", + "mail", + "calendar", + "drive", + "tasks", + ]; + + // Update active states for navigation (single selection only) + function updateNavigationActive(section) { + // Remove all active states first + document + .querySelectorAll(".app-tab") + .forEach((t) => t.classList.remove("active")); + document + .querySelectorAll(".app-item") + .forEach((i) => i.classList.remove("active")); + appsBtn.classList.remove("active"); + + // Check if section is in header tabs + const isInHeaderTabs = headerTabSections.includes(section); + + // Activate the matching app-tab if in header + if (isInHeaderTabs) { + const headerTab = document.querySelector( + `.app-tab[data-section="${section}"]`, + ); + if (headerTab) { + headerTab.classList.add("active"); + } + } else { + // Section is NOT in header tabs, activate apps button + appsBtn.classList.add("active"); + } + + // Always mark the app-item in dropdown as active + const appItem = document.querySelector( + `.app-item[data-section="${section}"]`, + ); + if (appItem) { + appItem.classList.add("active"); + } + } + + // Handle app item clicks - update active state + document.querySelectorAll(".app-item").forEach((item) => { + item.addEventListener("click", function () { + const section = this.getAttribute("data-section"); + updateNavigationActive(section); + appsDropdown.classList.remove("show"); + appsBtn.setAttribute("aria-expanded", "false"); + }); + }); + + // Handle app tab clicks + document.querySelectorAll(".app-tab").forEach((tab) => { + tab.addEventListener("click", function () { + const section = this.getAttribute("data-section"); + updateNavigationActive(section); + }); + }); + + // Track currently loaded section to prevent duplicate loads + let currentLoadedSection = null; + let isLoadingSection = false; + let pendingLoadTimeout = null; + + // Handle hash navigation + function handleHashChange(fromHtmxSwap = false) { + const hash = window.location.hash.slice(1) || "chat"; + + // Skip if already loaded this section or currently loading + if (currentLoadedSection === hash || isLoadingSection) { + return; + } + + // If this was triggered by HTMX swap, just update tracking + if (fromHtmxSwap) { + currentLoadedSection = hash; + return; + } + + updateNavigationActive(hash); + + // Abort any pending load timeout + if (pendingLoadTimeout) { + clearTimeout(pendingLoadTimeout); + pendingLoadTimeout = null; + } + + // Abort any in-flight HTMX requests for main-content + const mainContent = document.getElementById("main-content"); + if (mainContent) { + try { + htmx.trigger(mainContent, "htmx:abort"); + } catch (e) { + // Ignore abort errors + } + } + + // Validate target exists before triggering HTMX load + if (!mainContent) { + console.warn( + "handleHashChange: #main-content not found, skipping load", + ); + return; + } + + // Check if main-content is in the DOM + if (!document.body.contains(mainContent)) { + console.warn( + "handleHashChange: #main-content not in DOM, skipping load", + ); + return; + } + + // Verify main-content has a valid parent (prevents insertBefore errors) + if (!mainContent.parentNode) { + console.warn( + "handleHashChange: #main-content has no parent, skipping load", + ); + return; + } + + // Debounce the load to prevent rapid double-requests + pendingLoadTimeout = setTimeout(() => { + // Re-check if section changed during debounce + const currentHash = + window.location.hash.slice(1) || "chat"; + if (currentLoadedSection === currentHash) { + return; + } + + // Trigger HTMX load + const appItem = document.querySelector( + `.app-item[data-section="${currentHash}"]`, + ); + if (appItem) { + const hxGet = appItem.getAttribute("hx-get"); + if (hxGet) { + try { + isLoadingSection = true; + currentLoadedSection = currentHash; + htmx.ajax("GET", hxGet, { + target: "#main-content", + swap: "innerHTML", + }); + } catch (e) { + console.warn( + "handleHashChange: HTMX ajax error:", + e, + ); + currentLoadedSection = null; + isLoadingSection = false; + } + } + } + }, 50); + } + + // Listen for HTMX swaps to track loaded sections and prevent duplicates + document.body.addEventListener("htmx:afterSwap", (event) => { + if ( + event.detail.target && + event.detail.target.id === "main-content" + ) { + const hash = window.location.hash.slice(1) || "chat"; + currentLoadedSection = hash; + isLoadingSection = false; + } + }); + + // Reset tracking on swap errors + document.body.addEventListener("htmx:swapError", (event) => { + if ( + event.detail.target && + event.detail.target.id === "main-content" + ) { + isLoadingSection = false; + } + }); + + // Also listen for response errors + document.body.addEventListener( + "htmx:responseError", + (event) => { + if ( + event.detail.target && + event.detail.target.id === "main-content" + ) { + isLoadingSection = false; + currentLoadedSection = null; + } + }, + ); + + // Load initial content based on hash or default to chat + window.addEventListener("hashchange", handleHashChange); + + // Initial load - wait for HTMX to be ready + function initialLoad() { + if (typeof htmx !== "undefined" && htmx.ajax) { + handleHashChange(); + } else { + setTimeout(initialLoad, 50); + } + } + + if (document.readyState === "complete") { + setTimeout(initialLoad, 50); + } else { + window.addEventListener("load", () => { + setTimeout(initialLoad, 50); + }); + } + + // ========================================================================== + // GBAlerts - Global Notification System + // ========================================================================== + window.GBAlerts = (function () { + const notifications = []; + const badge = document.getElementById("notificationsBadge"); + const list = document.getElementById("notificationsList"); + const btn = document.getElementById("notificationsBtn"); + const panel = document.getElementById("notificationsPanel"); + const clearBtn = document.getElementById( + "clearNotificationsBtn", + ); + + function updateBadge() { + if (badge) { + if (notifications.length > 0) { + badge.textContent = + notifications.length > 99 + ? "99+" + : notifications.length; + badge.style.display = "flex"; + } else { + badge.style.display = "none"; + } + } + } + + function renderList() { + if (!list) return; + if (notifications.length === 0) { + list.innerHTML = + '
🔔

No notifications

'; + } else { + list.innerHTML = notifications + .map( + (n, i) => ` +
+
${n.icon || "📢"}
+
+
${n.title}
+
${n.message || ""}
+
${n.time}
+
+ ${n.action ? `` : ""} + +
+ `, + ) + .join(""); + } + } + + function add(notification) { + notifications.unshift({ + ...notification, + time: new Date().toLocaleTimeString(), + }); + updateBadge(); + renderList(); + // Auto-open panel when new notification arrives + if (panel) { + panel.classList.add("show"); + if (btn) btn.setAttribute("aria-expanded", "true"); + } + } + + function dismiss(index) { + notifications.splice(index, 1); + updateBadge(); + renderList(); + } + + function clearAll() { + notifications.length = 0; + updateBadge(); + renderList(); + } + + function handleAction(index) { + const n = notifications[index]; + if (n && n.action) { + if (typeof n.action === "function") { + n.action(); + } else if (typeof n.action === "string") { + window.open(n.action, "_blank"); + } + } + dismiss(index); + } + + // Toggle panel + if (btn && panel) { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const isOpen = panel.classList.toggle("show"); + btn.setAttribute("aria-expanded", isOpen); + }); + + document.addEventListener("click", (e) => { + if ( + !panel.contains(e.target) && + !btn.contains(e.target) + ) { + panel.classList.remove("show"); + btn.setAttribute("aria-expanded", "false"); + } + }); + } + + if (clearBtn) { + clearBtn.addEventListener("click", clearAll); + } + + // Convenience methods for common notifications + return { + add, + dismiss, + clearAll, + handleAction, + taskCompleted: function (title, url) { + add({ + type: "success", + icon: "✅", + title: "Task Completed", + message: title, + action: url, + actionText: "Open App", + }); + }, + taskFailed: function (title, error) { + add({ + type: "error", + icon: "❌", + title: "Task Failed", + message: title + (error ? ": " + error : ""), + }); + }, + info: function (title, message) { + add({ + type: "info", + icon: "ℹ️", + title: title, + message: message, + }); + }, + warning: function (title, message) { + add({ + type: "warning", + icon: "⚠️", + title: title, + message: message, + }); + }, + connectionStatus: function (status, message) { + add({ + type: + status === "connected" + ? "success" + : status === "disconnected" + ? "error" + : "warning", + icon: + status === "connected" + ? "🟢" + : status === "disconnected" + ? "🔴" + : "🟡", + title: + "Connection " + + status.charAt(0).toUpperCase() + + status.slice(1), + message: message || "", + }); + }, + }; + })(); + + // Keyboard shortcuts + document.addEventListener("keydown", (e) => { + // Alt + number for quick app switching + if (e.altKey && !e.ctrlKey && !e.shiftKey) { + const num = parseInt(e.key); + if (num >= 1 && num <= 9) { + const items = + document.querySelectorAll(".app-item"); + if (items[num - 1]) { + items[num - 1].click(); + e.preventDefault(); + } + } + } + + // Alt + A to open apps menu + if (e.altKey && e.key.toLowerCase() === "a") { + appsBtn.click(); + e.preventDefault(); + } + }); + }); + + // User Profile Loading + (function () { + function updateUserUI(user) { + if (!user) return; + + const userName = document.getElementById("userName"); + const userEmail = document.getElementById("userEmail"); + const userAvatar = document.getElementById("userAvatar"); + const userAvatarLarge = + document.getElementById("userAvatarLarge"); + const authAction = document.getElementById("authAction"); + const authText = document.getElementById("authText"); + const authIcon = document.getElementById("authIcon"); + + const displayName = + user.display_name || + user.first_name || + user.username || + "User"; + const email = user.email || ""; + const initial = ( + displayName.charAt(0) || "U" + ).toUpperCase(); + + console.log("Updating user UI:", displayName, email); + + if (userName) userName.textContent = displayName; + if (userEmail) userEmail.textContent = email; + if (userAvatar) { + const avatarSpan = userAvatar.querySelector("span"); + if (avatarSpan) avatarSpan.textContent = initial; + } + if (userAvatarLarge) userAvatarLarge.textContent = initial; + + if (authAction) { + authAction.href = "#"; + authAction.onclick = function (e) { + e.preventDefault(); + fetch("/api/auth/logout", { + method: "POST", + }).finally(function () { + localStorage.removeItem("gb-access-token"); + localStorage.removeItem("gb-refresh-token"); + localStorage.removeItem("gb-user-data"); + sessionStorage.removeItem("gb-access-token"); + window.location.href = "/auth/login.html"; + }); + }; + authAction.style.color = "var(--error)"; + } + if (authText) authText.textContent = "Sign out"; + if (authIcon) { + authIcon.innerHTML = + ''; + } + } + + function loadUserProfile() { + var token = + localStorage.getItem("gb-access-token") || + sessionStorage.getItem("gb-access-token"); + if (!token) { + console.log("No auth token found"); + return; + } + + console.log( + "Loading user profile with token:", + token.substring(0, 10) + "...", + ); + + fetch("/api/auth/me", { + headers: { Authorization: "Bearer " + token }, + }) + .then(function (res) { + if (!res.ok) throw new Error("Not authenticated"); + return res.json(); + }) + .then(function (user) { + console.log("User profile loaded:", user); + updateUserUI(user); + localStorage.setItem( + "gb-user-data", + JSON.stringify(user), + ); + }) + .catch(function (err) { + console.log("Failed to load user profile:", err); + }); + } + + // Try to load cached user first + var cachedUser = localStorage.getItem("gb-user-data"); + if (cachedUser) { + try { + var user = JSON.parse(cachedUser); + if (user && user.email) { + updateUserUI(user); + } + } catch (e) { } + } + + // Always fetch fresh user data + if (document.readyState === "loading") { + document.addEventListener( + "DOMContentLoaded", + loadUserProfile, + ); + } else { + loadUserProfile(); + } + })();