refactor: eliminate router duplication, graceful shutdown, data-driven static routes
Some checks failed
GBCI / build (push) Failing after 9s

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-20 19:58:04 -03:00
parent f6fc423d48
commit 1a50680712
6 changed files with 829 additions and 966 deletions

View file

@ -1,28 +1,63 @@
//! BotUI - General Bots Pure Web UI Server
//!
//! This is the entry point for the botui web server.
//! For desktop/mobile native features, see the `botapp` crate.
use log::info;
use std::net::SocketAddr;
mod shared;
mod ui_server;
#[tokio::main]
async fn main() -> std::io::Result<()> {
env_logger::init();
info!("BotUI starting...");
info!("Starting web UI server...");
fn init_logging() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.format_timestamp_millis()
.init();
}
let app = ui_server::configure_router();
let port: u16 = std::env::var("BOTUI_PORT")
fn get_port() -> u16 {
std::env::var("BOTUI_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(3000);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr).await?;
info!("UI server listening on {}", addr);
axum::serve(listener, app).await
.unwrap_or(3000)
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
init_logging();
info!("BotUI {} starting...", env!("CARGO_PKG_VERSION"));
let app = ui_server::configure_router();
let port = get_port();
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr).await?;
info!("UI server listening on http://{}", addr);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
info!("BotUI shutdown complete");
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("Failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("Failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => info!("Received Ctrl+C, shutting down..."),
_ = terminate => info!("Received SIGTERM, shutting down..."),
}
}

View file

@ -1,7 +1,3 @@
//! UI Server module for BotUI
//!
//! Serves the web UI (suite, minimal) and handles API proxying.
use axum::{
body::Body,
extract::{
@ -20,15 +16,38 @@ use std::{fs, path::PathBuf};
use tokio_tungstenite::{
connect_async_tls_with_config, tungstenite::protocol::Message as TungsteniteMessage,
};
use tower_http::services::ServeDir;
use crate::shared::AppState;
/// Serve the index page (suite UI)
const SUITE_DIRS: &[&str] = &[
"js",
"css",
"public",
"drive",
"chat",
"mail",
"tasks",
"calendar",
"meet",
"paper",
"research",
"analytics",
"monitoring",
"admin",
"auth",
"settings",
"sources",
"attendant",
"tools",
"assets",
"partials",
];
pub async fn index() -> impl IntoResponse {
serve_suite().await
}
/// Handler for minimal UI
pub async fn serve_minimal() -> impl IntoResponse {
match fs::read_to_string("ui/minimal/index.html") {
Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(html)),
@ -43,7 +62,6 @@ pub async fn serve_minimal() -> impl IntoResponse {
}
}
/// Handler for suite UI
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)),
@ -58,7 +76,6 @@ pub async fn serve_suite() -> impl IntoResponse {
}
}
/// Health check endpoint - checks BotServer connectivity
async fn health(State(state): State<AppState>) -> (StatusCode, axum::Json<serde_json::Value>) {
match state.health_check().await {
true => (
@ -80,7 +97,6 @@ async fn health(State(state): State<AppState>) -> (StatusCode, axum::Json<serde_
}
}
/// API health check endpoint
async fn api_health() -> (StatusCode, axum::Json<serde_json::Value>) {
(
StatusCode::OK,
@ -91,12 +107,9 @@ async fn api_health() -> (StatusCode, axum::Json<serde_json::Value>) {
)
}
/// Extract app context from Referer header or path
fn extract_app_context(headers: &axum::http::HeaderMap, path: &str) -> Option<String> {
// Try to extract from Referer header first
if let Some(referer) = headers.get("referer") {
if let Ok(referer_str) = referer.to_str() {
// Match /apps/{app_name}/ pattern
if let Some(start) = referer_str.find("/apps/") {
let after_apps = &referer_str[start + 6..];
if let Some(end) = after_apps.find('/') {
@ -108,7 +121,6 @@ fn extract_app_context(headers: &axum::http::HeaderMap, path: &str) -> Option<St
}
}
// Try to extract from path (for /apps/{app}/api/* routes)
if path.starts_with("/apps/") {
let after_apps = &path[6..];
if let Some(end) = after_apps.find('/') {
@ -119,7 +131,6 @@ fn extract_app_context(headers: &axum::http::HeaderMap, path: &str) -> Option<St
None
}
/// Proxy API requests to botserver
async fn proxy_api(
State(state): State<AppState>,
original_uri: OriginalUri,
@ -133,7 +144,6 @@ async fn proxy_api(
let method = req.method().clone();
let headers = req.headers().clone();
// Extract app context from request
let app_context = extract_app_context(&headers, path);
let target_url = format!("{}{}{}", state.client.base_url(), path, query);
@ -142,14 +152,12 @@ async fn proxy_api(
method, path, target_url, app_context
);
// Build the proxied request with self-signed cert support
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap_or_else(|_| reqwest::Client::new());
let mut proxy_req = client.request(method.clone(), &target_url);
// Copy headers (excluding host)
for (name, value) in headers.iter() {
if name != "host" {
if let Ok(v) = value.to_str() {
@ -158,12 +166,10 @@ async fn proxy_api(
}
}
// Inject X-App-Context header if we detected an app
if let Some(app) = app_context {
proxy_req = proxy_req.header("X-App-Context", app);
}
// Copy body for non-GET requests
let body_bytes = match axum::body::to_bytes(req.into_body(), usize::MAX).await {
Ok(bytes) => bytes,
Err(e) => {
@ -179,7 +185,6 @@ async fn proxy_api(
proxy_req = proxy_req.body(body_bytes.to_vec());
}
// Execute the request
match proxy_req.send().await {
Ok(resp) => {
let status = resp.status();
@ -189,7 +194,6 @@ async fn proxy_api(
Ok(body) => {
let mut response = Response::builder().status(status);
// Copy response headers
for (name, value) in headers.iter() {
response = response.header(name, value);
}
@ -220,32 +224,18 @@ async fn proxy_api(
}
}
/// Create API proxy router
fn create_api_router() -> Router<AppState> {
Router::new()
.route("/health", get(api_health))
.route("/chat", any(proxy_api))
.route("/sessions", any(proxy_api))
.route("/sessions/{id}", any(proxy_api))
.route("/sessions/{id}/history", any(proxy_api))
.route("/sessions/{id}/start", any(proxy_api))
.route("/drive/files", any(proxy_api))
.route("/drive/files/{path}", any(proxy_api))
.route("/drive/upload", any(proxy_api))
.route("/drive/download/{path}", any(proxy_api))
.route("/tasks", any(proxy_api))
.route("/tasks/{id}", any(proxy_api))
.fallback(any(proxy_api))
}
/// WebSocket query parameters
#[derive(Debug, Deserialize)]
struct WsQuery {
session_id: String,
user_id: String,
}
/// WebSocket proxy handler
async fn ws_proxy(
ws: WebSocketUpgrade,
State(state): State<AppState>,
@ -254,7 +244,6 @@ async fn ws_proxy(
ws.on_upgrade(move |socket| handle_ws_proxy(socket, state, params))
}
/// Handle WebSocket proxy connection
async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQuery) {
let backend_url = format!(
"{}/ws?session_id={}&user_id={}",
@ -269,7 +258,6 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
info!("Proxying WebSocket to: {}", backend_url);
// Create TLS connector that accepts self-signed certs
let tls_connector = native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
@ -278,7 +266,6 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
let connector = tokio_tungstenite::Connector::NativeTls(tls_connector);
// Connect to backend WebSocket
let backend_result =
connect_async_tls_with_config(&backend_url, None, false, Some(connector)).await;
@ -292,11 +279,9 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
info!("Connected to backend WebSocket");
// Split both sockets
let (mut client_tx, mut client_rx) = client_socket.split();
let (mut backend_tx, mut backend_rx) = backend_socket.split();
// Forward messages from client to backend
let client_to_backend = async {
while let Some(msg) = client_rx.next().await {
match msg {
@ -341,7 +326,6 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
}
};
// Forward messages from backend to client
let backend_to_client = async {
while let Some(msg) = backend_rx.next().await {
match msg {
@ -371,250 +355,57 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
}
};
// Run both forwarding tasks concurrently
tokio::select! {
_ = client_to_backend => info!("Client connection closed"),
_ = backend_to_client => info!("Backend connection closed"),
}
}
/// Create WebSocket proxy router
fn create_ws_router() -> Router<AppState> {
Router::new().fallback(any(ws_proxy))
}
/// Create apps proxy router - proxies /apps/* to botserver
fn create_apps_router() -> Router<AppState> {
Router::new()
// Proxy all /apps/* requests to botserver
// botserver serves static files from site_path
// API calls from apps also go through here with X-App-Context header
.fallback(any(proxy_api))
Router::new().fallback(any(proxy_api))
}
/// Create UI HTMX proxy router (for HTML fragment endpoints)
fn create_ui_router() -> Router<AppState> {
Router::new()
// Email UI endpoints
.route("/email/accounts", any(proxy_api))
.route("/email/list", any(proxy_api))
.route("/email/folders", any(proxy_api))
.route("/email/compose", any(proxy_api))
.route("/email/labels", any(proxy_api))
.route("/email/templates", any(proxy_api))
.route("/email/signatures", any(proxy_api))
.route("/email/rules", any(proxy_api))
.route("/email/search", any(proxy_api))
.route("/email/auto-responder", any(proxy_api))
.route("/email/{id}", any(proxy_api))
.route("/email/{id}/delete", any(proxy_api))
// Calendar UI endpoints
.route("/calendar/list", any(proxy_api))
.route("/calendar/upcoming", any(proxy_api))
.route("/calendar/event/new", any(proxy_api))
.route("/calendar/new", any(proxy_api))
// Fallback for any other /ui/* routes
.fallback(any(proxy_api))
Router::new().fallback(any(proxy_api))
}
fn add_static_routes(router: Router<AppState>, suite_path: &PathBuf) -> Router<AppState> {
let mut r = router;
for dir in SUITE_DIRS {
let path = suite_path.join(dir);
r = r
.nest_service(&format!("/suite/{}", dir), ServeDir::new(path.clone()))
.nest_service(&format!("/{}", dir), ServeDir::new(path));
}
r
}
/// Configure and return the main router
pub fn configure_router() -> Router {
let suite_path = PathBuf::from("./ui/suite");
let _minimal_path = PathBuf::from("./ui/minimal");
let state = AppState::new();
Router::new()
// Health check endpoints
let mut router = Router::new()
.route("/health", get(health))
// API proxy routes
.nest("/api", create_api_router())
// UI HTMX proxy routes (for /ui/* endpoints that return HTML fragments)
.nest("/ui", create_ui_router())
// WebSocket proxy routes
.nest("/ws", create_ws_router())
// Apps proxy routes - proxy /apps/* to botserver (which serves from site_path)
.nest("/apps", create_apps_router())
// UI routes
.route("/", get(index))
.route("/minimal", get(serve_minimal))
.route("/suite", get(serve_suite))
// Suite static assets (when accessing /suite/*)
.nest_service(
"/suite/js",
tower_http::services::ServeDir::new(suite_path.join("js")),
)
.nest_service(
"/suite/css",
tower_http::services::ServeDir::new(suite_path.join("css")),
)
.nest_service(
"/suite/public",
tower_http::services::ServeDir::new(suite_path.join("public")),
)
.nest_service(
"/suite/drive",
tower_http::services::ServeDir::new(suite_path.join("drive")),
)
.nest_service(
"/suite/chat",
tower_http::services::ServeDir::new(suite_path.join("chat")),
)
.nest_service(
"/suite/mail",
tower_http::services::ServeDir::new(suite_path.join("mail")),
)
.nest_service(
"/suite/tasks",
tower_http::services::ServeDir::new(suite_path.join("tasks")),
)
.nest_service(
"/suite/calendar",
tower_http::services::ServeDir::new(suite_path.join("calendar")),
)
.nest_service(
"/suite/meet",
tower_http::services::ServeDir::new(suite_path.join("meet")),
)
.nest_service(
"/suite/paper",
tower_http::services::ServeDir::new(suite_path.join("paper")),
)
.nest_service(
"/suite/research",
tower_http::services::ServeDir::new(suite_path.join("research")),
)
.nest_service(
"/suite/analytics",
tower_http::services::ServeDir::new(suite_path.join("analytics")),
)
.nest_service(
"/suite/monitoring",
tower_http::services::ServeDir::new(suite_path.join("monitoring")),
)
.nest_service(
"/suite/admin",
tower_http::services::ServeDir::new(suite_path.join("admin")),
)
.nest_service(
"/suite/auth",
tower_http::services::ServeDir::new(suite_path.join("auth")),
)
.nest_service(
"/suite/settings",
tower_http::services::ServeDir::new(suite_path.join("settings")),
)
.nest_service(
"/suite/sources",
tower_http::services::ServeDir::new(suite_path.join("sources")),
)
.nest_service(
"/suite/attendant",
tower_http::services::ServeDir::new(suite_path.join("attendant")),
)
.nest_service(
"/suite/tools",
tower_http::services::ServeDir::new(suite_path.join("tools")),
)
.nest_service(
"/suite/assets",
tower_http::services::ServeDir::new(suite_path.join("assets")),
)
.nest_service(
"/suite/partials",
tower_http::services::ServeDir::new(suite_path.join("partials")),
)
// Legacy paths for backward compatibility (serve suite assets)
.nest_service(
"/js",
tower_http::services::ServeDir::new(suite_path.join("js")),
)
.nest_service(
"/css",
tower_http::services::ServeDir::new(suite_path.join("css")),
)
.nest_service(
"/public",
tower_http::services::ServeDir::new(suite_path.join("public")),
)
.nest_service(
"/drive",
tower_http::services::ServeDir::new(suite_path.join("drive")),
)
.nest_service(
"/chat",
tower_http::services::ServeDir::new(suite_path.join("chat")),
)
.nest_service(
"/mail",
tower_http::services::ServeDir::new(suite_path.join("mail")),
)
.nest_service(
"/tasks",
tower_http::services::ServeDir::new(suite_path.join("tasks")),
)
// Additional app routes
.nest_service(
"/paper",
tower_http::services::ServeDir::new(suite_path.join("paper")),
)
.nest_service(
"/calendar",
tower_http::services::ServeDir::new(suite_path.join("calendar")),
)
.nest_service(
"/research",
tower_http::services::ServeDir::new(suite_path.join("research")),
)
.nest_service(
"/meet",
tower_http::services::ServeDir::new(suite_path.join("meet")),
)
.nest_service(
"/analytics",
tower_http::services::ServeDir::new(suite_path.join("analytics")),
)
.nest_service(
"/monitoring",
tower_http::services::ServeDir::new(suite_path.join("monitoring")),
)
.nest_service(
"/admin",
tower_http::services::ServeDir::new(suite_path.join("admin")),
)
.nest_service(
"/auth",
tower_http::services::ServeDir::new(suite_path.join("auth")),
)
.nest_service(
"/settings",
tower_http::services::ServeDir::new(suite_path.join("settings")),
)
.nest_service(
"/sources",
tower_http::services::ServeDir::new(suite_path.join("sources")),
)
.nest_service(
"/tools",
tower_http::services::ServeDir::new(suite_path.join("tools")),
)
.nest_service(
"/assets",
tower_http::services::ServeDir::new(suite_path.join("assets")),
)
.nest_service(
"/partials",
tower_http::services::ServeDir::new(suite_path.join("partials")),
)
.nest_service(
"/attendant",
tower_http::services::ServeDir::new(suite_path.join("attendant")),
)
// Fallback for other static files (serve suite by default)
.route("/suite", get(serve_suite));
router = add_static_routes(router, &suite_path);
router
.fallback_service(
tower_http::services::ServeDir::new(suite_path.clone()).fallback(
tower_http::services::ServeDir::new(suite_path)
.append_index_html_on_directories(true),
),
ServeDir::new(suite_path.clone())
.fallback(ServeDir::new(suite_path).append_index_html_on_directories(true)),
)
.with_state(state)
}

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,8 @@
/**
* Base Module JavaScript
* Core functionality for the General Bots Suite
* Handles navigation, theme, settings, accessibility, and HTMX error handling
*/
// DOM Elements
const appsBtn = document.getElementById("apps-btn");
const appsDropdown = document.getElementById("apps-dropdown");
const settingsBtn = document.getElementById("settings-btn");
const settingsPanel = document.getElementById("settings-panel");
// Apps Menu Toggle
if (appsBtn) {
appsBtn.addEventListener("click", (e) => {
e.stopPropagation();
@ -20,7 +12,6 @@ if (appsBtn) {
});
}
// Settings Panel Toggle
if (settingsBtn) {
settingsBtn.addEventListener("click", (e) => {
e.stopPropagation();
@ -30,7 +21,6 @@ if (settingsBtn) {
});
}
// Close dropdowns when clicking outside
document.addEventListener("click", (e) => {
if (
appsDropdown &&
@ -50,7 +40,6 @@ document.addEventListener("click", (e) => {
}
});
// Escape key closes dropdowns
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (appsDropdown) appsDropdown.classList.remove("show");
@ -60,7 +49,6 @@ document.addEventListener("keydown", (e) => {
}
});
// Alt+key shortcuts for navigation
document.addEventListener("keydown", (e) => {
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
const shortcuts = {
@ -95,7 +83,6 @@ document.addEventListener("keydown", (e) => {
}
});
// Update active app on HTMX swap
document.body.addEventListener("htmx:afterSwap", (e) => {
if (e.detail.target.id === "main-content") {
const hash = window.location.hash || "#chat";
@ -106,8 +93,6 @@ document.body.addEventListener("htmx:afterSwap", (e) => {
}
});
// Theme handling
// Available themes: dark, light, blue, purple, green, orange, sentient
const themeOptions = document.querySelectorAll(".theme-option");
const savedTheme = localStorage.getItem("gb-theme") || "sentient";
document.body.setAttribute("data-theme", savedTheme);
@ -115,10 +100,8 @@ document
.querySelector(`.theme-option[data-theme="${savedTheme}"]`)
?.classList.add("active");
// Update theme-color meta tag based on theme
function updateThemeColor(theme) {
const themeColors = {
// Core Themes
dark: "#3b82f6",
light: "#3b82f6",
blue: "#0ea5e9",
@ -126,7 +109,6 @@ function updateThemeColor(theme) {
green: "#22c55e",
orange: "#f97316",
sentient: "#d4f505",
// Retro Themes
cyberpunk: "#ff00ff",
retrowave: "#ff6b9d",
vapordream: "#a29bfe",
@ -134,7 +116,6 @@ function updateThemeColor(theme) {
arcadeflash: "#ffff00",
discofever: "#ff1493",
grungeera: "#8b4513",
// Classic Themes
jazzage: "#d4af37",
mellowgold: "#daa520",
midcenturymod: "#e07b39",
@ -142,7 +123,6 @@ function updateThemeColor(theme) {
saturdaycartoons: "#ff6347",
seasidepostcard: "#20b2aa",
typewriter: "#2f2f2f",
// Tech Themes
"3dbevel": "#0000ff",
xeroxui: "#4a86cf",
xtreegold: "#ffff00",
@ -165,7 +145,6 @@ themeOptions.forEach((option) => {
});
});
// Global theme setter function (can be called from settings or elsewhere)
window.setTheme = function (theme) {
document.body.setAttribute("data-theme", theme);
localStorage.setItem("gb-theme", theme);
@ -175,14 +154,12 @@ window.setTheme = function (theme) {
updateThemeColor(theme);
};
// Quick Settings Toggle
function toggleQuickSetting(el) {
el.classList.toggle("active");
const setting = el.id.replace("toggle-", "");
localStorage.setItem(`gb-${setting}`, el.classList.contains("active"));
}
// Load quick toggle states
["notifications", "sound", "compact"].forEach((setting) => {
const saved = localStorage.getItem(`gb-${setting}`);
const toggle = document.getElementById(`toggle-${setting}`);
@ -191,7 +168,6 @@ function toggleQuickSetting(el) {
}
});
// Show keyboard shortcuts notification
function showKeyboardShortcuts() {
window.showNotification(
"Alt+1-9,0 for apps, Alt+A Admin, Alt+M Monitoring, Alt+S Settings, Alt+, quick settings",
@ -200,19 +176,16 @@ function showKeyboardShortcuts() {
);
}
// Accessibility: Announce page changes to screen readers
function announceToScreenReader(message) {
const liveRegion = document.getElementById("aria-live");
if (liveRegion) {
liveRegion.textContent = message;
// Clear after announcement
setTimeout(() => {
liveRegion.textContent = "";
}, 1000);
}
}
// HTMX accessibility hooks
document.body.addEventListener("htmx:beforeRequest", function (e) {
const target = e.detail.target;
if (target && target.id === "main-content") {
@ -225,7 +198,6 @@ document.body.addEventListener("htmx:afterSwap", function (e) {
const target = e.detail.target;
if (target && target.id === "main-content") {
target.setAttribute("aria-busy", "false");
// Focus management: move focus to main content after navigation
target.focus();
announceToScreenReader("Content loaded");
}
@ -239,7 +211,6 @@ document.body.addEventListener("htmx:responseError", function (e) {
announceToScreenReader("Error loading content. Please try again.");
});
// Keyboard navigation for apps grid
document.addEventListener("keydown", function (e) {
const appsGrid = document.querySelector(".apps-grid");
if (!appsGrid || !appsGrid.closest(".show")) return;
@ -252,7 +223,7 @@ document.addEventListener("keydown", function (e) {
if (currentIndex === -1) return;
let newIndex = currentIndex;
const columns = 3; // Grid has 3 columns on desktop
const columns = 3;
switch (e.key) {
case "ArrowRight":
@ -283,7 +254,6 @@ document.addEventListener("keydown", function (e) {
}
});
// Notification System
window.showNotification = function (message, type = "info", duration = 5000) {
const container = document.getElementById("notifications");
if (!container) return;
@ -302,7 +272,6 @@ window.showNotification = function (message, type = "info", duration = 5000) {
}
};
// Global HTMX error handling with retry mechanism
const htmxRetryConfig = {
maxRetries: 3,
retryDelay: 1000,
@ -357,12 +326,10 @@ window.retryLastRequest = function (btn) {
if (retryCallback && window[retryCallback]) {
window[retryCallback]();
} else {
// Try to re-trigger HTMX request
const triggers = target.querySelectorAll("[hx-get], [hx-post]");
if (triggers.length > 0) {
htmx.trigger(triggers[0], "htmx:trigger");
} else {
// Reload the current app
const activeApp = document.querySelector(".app-item.active");
if (activeApp) {
activeApp.click();
@ -371,7 +338,6 @@ window.retryLastRequest = function (btn) {
}
};
// Handle HTMX errors globally
document.body.addEventListener("htmx:responseError", function (e) {
const target = e.detail.target;
const xhr = e.detail.xhr;
@ -379,7 +345,6 @@ document.body.addEventListener("htmx:responseError", function (e) {
let currentRetries = htmxRetryConfig.retryCount.get(retryKey) || 0;
// Auto-retry for network errors (status 0) or server errors (5xx)
if (
(xhr.status === 0 || xhr.status >= 500) &&
currentRetries < htmxRetryConfig.maxRetries
@ -397,7 +362,6 @@ document.body.addEventListener("htmx:responseError", function (e) {
htmx.trigger(e.detail.elt, "htmx:trigger");
}, delay);
} else {
// Max retries reached or client error - show error state
htmxRetryConfig.retryCount.delete(retryKey);
let errorMessage = "We couldn't load the content.";
@ -423,7 +387,6 @@ document.body.addEventListener("htmx:responseError", function (e) {
}
});
// Clear retry count on successful request
document.body.addEventListener("htmx:afterRequest", function (e) {
if (e.detail.successful) {
const retryKey = getRetryKey(e.detail.elt);
@ -431,7 +394,6 @@ document.body.addEventListener("htmx:afterRequest", function (e) {
}
});
// Handle timeout errors
document.body.addEventListener("htmx:timeout", function (e) {
window.showNotification(
"Request timed out. Please try again.",
@ -440,7 +402,6 @@ document.body.addEventListener("htmx:timeout", function (e) {
);
});
// Handle send errors (network issues before request sent)
document.body.addEventListener("htmx:sendError", function (e) {
window.showNotification(
"Network error. Please check your connection.",

View file

@ -1,173 +1,156 @@
/**
* Mail Module JavaScript
* Email client functionality including compose, selection, and modals
*/
// Compose Modal Functions
function openCompose(replyTo = null, forward = null) {
const modal = document.getElementById('composeModal');
if (modal) {
modal.classList.remove('hidden');
modal.classList.remove('minimized');
if (replyTo) {
document.getElementById('composeTo').value = replyTo;
}
const modal = document.getElementById("composeModal");
if (modal) {
modal.classList.remove("hidden");
modal.classList.remove("minimized");
if (replyTo) {
document.getElementById("composeTo").value = replyTo;
}
}
}
function closeCompose() {
const modal = document.getElementById('composeModal');
if (modal) {
modal.classList.add('hidden');
// Clear form
document.getElementById('composeTo').value = '';
document.getElementById('composeCc').value = '';
document.getElementById('composeBcc').value = '';
document.getElementById('composeSubject').value = '';
document.getElementById('composeBody').value = '';
}
const modal = document.getElementById("composeModal");
if (modal) {
modal.classList.add("hidden");
document.getElementById("composeTo").value = "";
document.getElementById("composeCc").value = "";
document.getElementById("composeBcc").value = "";
document.getElementById("composeSubject").value = "";
document.getElementById("composeBody").value = "";
}
}
function minimizeCompose() {
const modal = document.getElementById('composeModal');
if (modal) {
modal.classList.toggle('minimized');
}
const modal = document.getElementById("composeModal");
if (modal) {
modal.classList.toggle("minimized");
}
}
function toggleCcBcc() {
const ccBcc = document.getElementById('ccBccFields');
if (ccBcc) {
ccBcc.classList.toggle('hidden');
}
const ccBcc = document.getElementById("ccBccFields");
if (ccBcc) {
ccBcc.classList.toggle("hidden");
}
}
// Schedule Functions
function toggleScheduleMenu() {
const menu = document.getElementById('scheduleMenu');
if (menu) {
menu.classList.toggle('hidden');
}
const menu = document.getElementById("scheduleMenu");
if (menu) {
menu.classList.toggle("hidden");
}
}
function scheduleSend(when) {
console.log('Scheduling send for:', when);
toggleScheduleMenu();
console.log("Scheduling send for:", when);
toggleScheduleMenu();
}
// Selection Functions
function toggleSelectAll() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.email-checkbox');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateBulkActions();
const selectAll = document.getElementById("selectAll");
const checkboxes = document.querySelectorAll(".email-checkbox");
checkboxes.forEach((cb) => (cb.checked = selectAll.checked));
updateBulkActions();
}
function updateBulkActions() {
const checked = document.querySelectorAll('.email-checkbox:checked');
const bulkActions = document.getElementById('bulkActions');
if (bulkActions) {
bulkActions.style.display = checked.length > 0 ? 'flex' : 'none';
}
const checked = document.querySelectorAll(".email-checkbox:checked");
const bulkActions = document.getElementById("bulkActions");
if (bulkActions) {
bulkActions.style.display = checked.length > 0 ? "flex" : "none";
}
}
// Modal Functions
function openTemplatesModal() {
const modal = document.getElementById('templatesModal');
if (modal) modal.classList.remove('hidden');
const modal = document.getElementById("templatesModal");
if (modal) modal.classList.remove("hidden");
}
function closeTemplatesModal() {
const modal = document.getElementById('templatesModal');
if (modal) modal.classList.add('hidden');
const modal = document.getElementById("templatesModal");
if (modal) modal.classList.add("hidden");
}
function openSignaturesModal() {
const modal = document.getElementById('signaturesModal');
if (modal) modal.classList.remove('hidden');
const modal = document.getElementById("signaturesModal");
if (modal) modal.classList.remove("hidden");
}
function closeSignaturesModal() {
const modal = document.getElementById('signaturesModal');
if (modal) modal.classList.add('hidden');
const modal = document.getElementById("signaturesModal");
if (modal) modal.classList.add("hidden");
}
function openRulesModal() {
const modal = document.getElementById('rulesModal');
if (modal) modal.classList.remove('hidden');
const modal = document.getElementById("rulesModal");
if (modal) modal.classList.remove("hidden");
}
function closeRulesModal() {
const modal = document.getElementById('rulesModal');
if (modal) modal.classList.add('hidden');
const modal = document.getElementById("rulesModal");
if (modal) modal.classList.add("hidden");
}
function useTemplate(name) {
console.log('Using template:', name);
closeTemplatesModal();
console.log("Using template:", name);
closeTemplatesModal();
}
function useSignature(name) {
console.log('Using signature:', name);
closeSignaturesModal();
console.log("Using signature:", name);
closeSignaturesModal();
}
// Bulk Actions
function archiveSelected() {
const checked = document.querySelectorAll('.email-checkbox:checked');
console.log('Archiving', checked.length, 'emails');
const checked = document.querySelectorAll(".email-checkbox:checked");
console.log("Archiving", checked.length, "emails");
}
function deleteSelected() {
const checked = document.querySelectorAll('.email-checkbox:checked');
if (confirm(`Delete ${checked.length} email(s)?`)) {
console.log('Deleting', checked.length, 'emails');
}
const checked = document.querySelectorAll(".email-checkbox:checked");
if (confirm(`Delete ${checked.length} email(s)?`)) {
console.log("Deleting", checked.length, "emails");
}
}
function markSelectedRead() {
const checked = document.querySelectorAll('.email-checkbox:checked');
console.log('Marking', checked.length, 'emails as read');
const checked = document.querySelectorAll(".email-checkbox:checked");
console.log("Marking", checked.length, "emails as read");
}
// File Attachment
function handleAttachment(input) {
const files = input.files;
const attachmentList = document.getElementById('attachmentList');
if (attachmentList && files.length > 0) {
for (const file of files) {
const item = document.createElement('div');
item.className = 'attachment-item';
item.innerHTML = `
const files = input.files;
const attachmentList = document.getElementById("attachmentList");
if (attachmentList && files.length > 0) {
for (const file of files) {
const item = document.createElement("div");
item.className = "attachment-item";
item.innerHTML = `
<span>${file.name}</span>
<button type="button" onclick="this.parentElement.remove()">×</button>
`;
attachmentList.appendChild(item);
}
attachmentList.appendChild(item);
}
}
}
// Keyboard Shortcuts
document.addEventListener('keydown', function(e) {
// Escape closes modals
if (e.key === 'Escape') {
closeCompose();
closeTemplatesModal();
closeSignaturesModal();
closeRulesModal();
}
// Ctrl+N for new email
if (e.ctrlKey && e.key === 'n') {
e.preventDefault();
openCompose();
}
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") {
closeCompose();
closeTemplatesModal();
closeSignaturesModal();
closeRulesModal();
}
if (e.ctrlKey && e.key === "n") {
e.preventDefault();
openCompose();
}
});
// Initialize
document.addEventListener('DOMContentLoaded', function() {
// Add change listeners to checkboxes
document.querySelectorAll('.email-checkbox').forEach(cb => {
cb.addEventListener('change', updateBulkActions);
});
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".email-checkbox").forEach((cb) => {
cb.addEventListener("change", updateBulkActions);
});
});

View file

@ -373,8 +373,121 @@ function discardPlan() {
}
function editPlan() {
// TODO: Implement plan editor
showToast("Plan editor coming soon!", "info");
if (!AutoTaskState.compiledPlan) {
showToast("No plan to edit", "warning");
return;
}
const modal = document.createElement("div");
modal.className = "modal-overlay";
modal.id = "plan-editor-modal";
modal.innerHTML = `
<div class="modal-content large">
<div class="modal-header">
<h3>Edit Plan</h3>
<button class="close-btn" onclick="closePlanEditor()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="plan-name">Plan Name</label>
<input type="text" id="plan-name" value="${AutoTaskState.compiledPlan.name || "Untitled Plan"}" />
</div>
<div class="form-group">
<label for="plan-description">Description</label>
<textarea id="plan-description" rows="3">${AutoTaskState.compiledPlan.description || ""}</textarea>
</div>
<div class="form-group">
<label for="plan-steps">Steps (JSON)</label>
<textarea id="plan-steps" rows="10" class="code-editor">${JSON.stringify(AutoTaskState.compiledPlan.steps || [], null, 2)}</textarea>
</div>
<div class="form-group">
<label for="plan-priority">Priority</label>
<select id="plan-priority">
<option value="low" ${AutoTaskState.compiledPlan.priority === "low" ? "selected" : ""}>Low</option>
<option value="medium" ${AutoTaskState.compiledPlan.priority === "medium" ? "selected" : ""}>Medium</option>
<option value="high" ${AutoTaskState.compiledPlan.priority === "high" ? "selected" : ""}>High</option>
<option value="urgent" ${AutoTaskState.compiledPlan.priority === "urgent" ? "selected" : ""}>Urgent</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closePlanEditor()">Cancel</button>
<button class="btn btn-primary" onclick="savePlanEdits()">Save Changes</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
function closePlanEditor() {
const modal = document.getElementById("plan-editor-modal");
if (modal) {
modal.remove();
}
}
function savePlanEdits() {
const name = document.getElementById("plan-name").value;
const description = document.getElementById("plan-description").value;
const stepsJson = document.getElementById("plan-steps").value;
const priority = document.getElementById("plan-priority").value;
let steps;
try {
steps = JSON.parse(stepsJson);
} catch (e) {
showToast("Invalid JSON in steps", "error");
return;
}
AutoTaskState.compiledPlan = {
...AutoTaskState.compiledPlan,
name: name,
description: description,
steps: steps,
priority: priority,
};
closePlanEditor();
showToast("Plan updated successfully", "success");
const resultDiv = document.getElementById("compilation-result");
if (resultDiv && AutoTaskState.compiledPlan) {
renderCompiledPlan(AutoTaskState.compiledPlan);
}
}
function renderCompiledPlan(plan) {
const resultDiv = document.getElementById("compilation-result");
if (!resultDiv) return;
const stepsHtml = (plan.steps || [])
.map(
(step, i) => `
<div class="plan-step">
<span class="step-number">${i + 1}</span>
<span class="step-action">${step.action || step.type || "Action"}</span>
<span class="step-target">${step.target || step.description || ""}</span>
</div>
`,
)
.join("");
resultDiv.innerHTML = `
<div class="compiled-plan">
<div class="plan-header">
<h4>${plan.name || "Compiled Plan"}</h4>
<span class="plan-priority priority-${plan.priority || "medium"}">${plan.priority || "medium"}</span>
</div>
${plan.description ? `<p class="plan-description">${plan.description}</p>` : ""}
<div class="plan-steps">${stepsHtml}</div>
<div class="plan-actions">
<button class="btn btn-secondary" onclick="editPlan()">Edit</button>
<button class="btn btn-secondary" onclick="discardPlan()">Discard</button>
<button class="btn btn-primary" onclick="executePlan('${plan.id || ""}')">Execute</button>
</div>
</div>
`;
}
// =============================================================================