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)
}

View file

@ -1,6 +1,3 @@
/* Chat module JavaScript - including projector component */
// Projector State
let projectorState = {
isOpen: false,
contentType: null,
@ -16,92 +13,79 @@ let projectorState = {
isLooping: false,
isMuted: false,
lineNumbers: true,
wordWrap: false
wordWrap: false,
};
// Get media element
function getMediaElement() {
return document.querySelector('.projector-video, .projector-audio');
return document.querySelector(".projector-video, .projector-audio");
}
// Open Projector
function openProjector(data) {
const overlay = document.getElementById('projector-overlay');
const content = document.getElementById('projector-content');
const loading = document.getElementById('projector-loading');
const title = document.getElementById('projector-title');
const icon = document.getElementById('projector-icon');
// Reset state
const overlay = document.getElementById("projector-overlay");
const content = document.getElementById("projector-content");
const loading = document.getElementById("projector-loading");
const title = document.getElementById("projector-title");
const icon = document.getElementById("projector-icon");
projectorState = {
...projectorState,
isOpen: true,
contentType: data.content_type,
source: data.source_url,
options: data.options || {}
options: data.options || {},
};
title.textContent = data.title || "Content Viewer";
// Set title
title.textContent = data.title || 'Content Viewer';
// Set icon based on content type
const icons = {
'Video': '🎬',
'Audio': '🎵',
'Image': '🖼️',
'Pdf': '📄',
'Presentation': '📊',
'Code': '💻',
'Spreadsheet': '📈',
'Markdown': '📝',
'Html': '🌐',
'Document': '📃'
Video: "🎬",
Audio: "🎵",
Image: "🖼️",
Pdf: "📄",
Presentation: "📊",
Code: "💻",
Spreadsheet: "📈",
Markdown: "📝",
Html: "🌐",
Document: "📃",
};
icon.textContent = icons[data.content_type] || '📁';
// Show loading
loading.classList.remove('hidden');
icon.textContent = icons[data.content_type] || "📁";
loading.classList.remove("hidden");
hideAllControls();
// Show overlay
overlay.classList.remove('hidden');
// Load content based on type
overlay.classList.remove("hidden");
loadContent(data);
}
// Load Content
function loadContent(data) {
const content = document.getElementById('projector-content');
const loading = document.getElementById('projector-loading');
const content = document.getElementById("projector-content");
const loading = document.getElementById("projector-loading");
setTimeout(() => {
loading.classList.add('hidden');
loading.classList.add("hidden");
switch (data.content_type) {
case 'Video':
case "Video":
loadVideo(content, data);
break;
case 'Audio':
case "Audio":
loadAudio(content, data);
break;
case 'Image':
case "Image":
loadImage(content, data);
break;
case 'Pdf':
case "Pdf":
loadPdf(content, data);
break;
case 'Presentation':
case "Presentation":
loadPresentation(content, data);
break;
case 'Code':
case "Code":
loadCode(content, data);
break;
case 'Markdown':
case "Markdown":
loadMarkdown(content, data);
break;
case 'Iframe':
case 'Html':
case "Iframe":
case "Html":
loadIframe(content, data);
break;
default:
@ -112,81 +96,76 @@ function loadContent(data) {
// Load Video
function loadVideo(container, data) {
const loading = document.getElementById('projector-loading');
const loading = document.getElementById("projector-loading");
const video = document.createElement('video');
video.className = 'projector-video';
const video = document.createElement("video");
video.className = "projector-video";
video.src = data.source_url;
video.controls = false;
video.autoplay = data.options?.autoplay || false;
video.loop = data.options?.loop_content || false;
video.muted = data.options?.muted || false;
video.addEventListener('loadedmetadata', () => {
loading.classList.add('hidden');
video.addEventListener("loadedmetadata", () => {
loading.classList.add("hidden");
updateTimeDisplay();
});
video.addEventListener('timeupdate', () => {
video.addEventListener("timeupdate", () => {
updateProgress();
updateTimeDisplay();
});
video.addEventListener('play', () => {
video.addEventListener("play", () => {
projectorState.isPlaying = true;
document.getElementById('play-pause-btn').textContent = '⏸️';
document.getElementById("play-pause-btn").textContent = "⏸️";
});
video.addEventListener('pause', () => {
video.addEventListener("pause", () => {
projectorState.isPlaying = false;
document.getElementById('play-pause-btn').textContent = '▶️';
document.getElementById("play-pause-btn").textContent = "▶️";
});
video.addEventListener('ended', () => {
video.addEventListener("ended", () => {
if (!projectorState.isLooping) {
projectorState.isPlaying = false;
document.getElementById('play-pause-btn').textContent = '▶️';
document.getElementById("play-pause-btn").textContent = "▶️";
}
});
// Clear and add video
clearContent(container);
container.appendChild(video);
// Show media controls
showControls('media');
showControls("media");
}
// Load Audio
function loadAudio(container, data) {
const wrapper = document.createElement('div');
wrapper.style.textAlign = 'center';
wrapper.style.padding = '40px';
const wrapper = document.createElement("div");
wrapper.style.textAlign = "center";
wrapper.style.padding = "40px";
// Visualizer placeholder
const visualizer = document.createElement('canvas');
visualizer.className = 'audio-visualizer';
visualizer.id = 'audio-visualizer';
const visualizer = document.createElement("canvas");
visualizer.className = "audio-visualizer";
visualizer.id = "audio-visualizer";
wrapper.appendChild(visualizer);
const audio = document.createElement('audio');
audio.className = 'projector-audio';
const audio = document.createElement("audio");
audio.className = "projector-audio";
audio.src = data.source_url;
audio.autoplay = data.options?.autoplay || false;
audio.loop = data.options?.loop_content || false;
audio.addEventListener('loadedmetadata', () => updateTimeDisplay());
audio.addEventListener('timeupdate', () => {
audio.addEventListener("loadedmetadata", () => updateTimeDisplay());
audio.addEventListener("timeupdate", () => {
updateProgress();
updateTimeDisplay();
});
audio.addEventListener('play', () => {
audio.addEventListener("play", () => {
projectorState.isPlaying = true;
document.getElementById('play-pause-btn').textContent = '⏸️';
document.getElementById("play-pause-btn").textContent = "⏸️";
});
audio.addEventListener('pause', () => {
audio.addEventListener("pause", () => {
projectorState.isPlaying = false;
document.getElementById('play-pause-btn').textContent = '▶️';
document.getElementById("play-pause-btn").textContent = "▶️";
});
wrapper.appendChild(audio);
@ -194,63 +173,60 @@ function loadAudio(container, data) {
clearContent(container);
container.appendChild(wrapper);
showControls('media');
showControls("media");
}
// Load Image
function loadImage(container, data) {
const img = document.createElement('img');
img.className = 'projector-image';
const img = document.createElement("img");
img.className = "projector-image";
img.src = data.source_url;
img.alt = data.title || 'Image';
img.id = 'projector-img';
img.alt = data.title || "Image";
img.id = "projector-img";
img.addEventListener('load', () => {
document.getElementById('projector-loading').classList.add('hidden');
img.addEventListener("load", () => {
document.getElementById("projector-loading").classList.add("hidden");
});
img.addEventListener('error', () => {
showError('Failed to load image');
img.addEventListener("error", () => {
showError("Failed to load image");
});
clearContent(container);
container.appendChild(img);
document.getElementById("prev-image-btn").style.display =
projectorState.totalImages > 1 ? "block" : "none";
document.getElementById("next-image-btn").style.display =
projectorState.totalImages > 1 ? "block" : "none";
// Hide nav if single image
document.getElementById('prev-image-btn').style.display =
projectorState.totalImages > 1 ? 'block' : 'none';
document.getElementById('next-image-btn').style.display =
projectorState.totalImages > 1 ? 'block' : 'none';
showControls('image');
showControls("image");
updateImageInfo();
}
// Load PDF
function loadPdf(container, data) {
const iframe = document.createElement('iframe');
iframe.className = 'projector-pdf';
const iframe = document.createElement("iframe");
iframe.className = "projector-pdf";
iframe.src = `/static/pdfjs/web/viewer.html?file=${encodeURIComponent(data.source_url)}`;
clearContent(container);
container.appendChild(iframe);
showControls('slide');
showControls("slide");
}
// Load Presentation
function loadPresentation(container, data) {
const wrapper = document.createElement('div');
wrapper.className = 'projector-presentation';
const wrapper = document.createElement("div");
wrapper.className = "projector-presentation";
const slideContainer = document.createElement('div');
slideContainer.className = 'slide-container';
slideContainer.id = 'slide-container';
const slideContainer = document.createElement("div");
slideContainer.className = "slide-container";
slideContainer.id = "slide-container";
// For now, show as images (each slide converted to image)
const slideImg = document.createElement('img');
slideImg.className = 'slide-content';
slideImg.id = 'slide-content';
const slideImg = document.createElement("img");
slideImg.className = "slide-content";
slideImg.id = "slide-content";
slideImg.src = `${data.source_url}?slide=1`;
slideContainer.appendChild(slideImg);
@ -259,39 +235,37 @@ function loadPresentation(container, data) {
clearContent(container);
container.appendChild(wrapper);
showControls('slide');
showControls("slide");
updateSlideInfo();
}
// Load Code
function loadCode(container, data) {
const wrapper = document.createElement('div');
wrapper.className = 'projector-code';
wrapper.id = 'code-container';
const wrapper = document.createElement("div");
wrapper.className = "projector-code";
wrapper.id = "code-container";
if (projectorState.lineNumbers) {
wrapper.classList.add('line-numbers');
wrapper.classList.add("line-numbers");
}
const pre = document.createElement('pre');
const code = document.createElement('code');
const pre = document.createElement("pre");
const code = document.createElement("code");
// Fetch code content
fetch(data.source_url)
.then(res => res.text())
.then(text => {
// Split into lines for line numbers
const lines = text.split('\n').map(line =>
`<span class="line">${escapeHtml(line)}</span>`
).join('\n');
.then((res) => res.text())
.then((text) => {
const lines = text
.split("\n")
.map((line) => `<span class="line">${escapeHtml(line)}</span>`)
.join("\n");
code.innerHTML = lines;
// Apply syntax highlighting if Prism is available
if (window.Prism) {
Prism.highlightElement(code);
}
})
.catch(() => {
code.textContent = 'Failed to load code';
code.textContent = "Failed to load code";
});
pre.appendChild(code);
@ -299,27 +273,24 @@ function loadCode(container, data) {
clearContent(container);
container.appendChild(wrapper);
const filename = data.source_url.split("/").pop();
document.getElementById("code-info").textContent = filename;
// Update code info
const filename = data.source_url.split('/').pop();
document.getElementById('code-info').textContent = filename;
showControls('code');
showControls("code");
}
// Load Markdown
function loadMarkdown(container, data) {
const wrapper = document.createElement('div');
wrapper.className = 'projector-markdown';
const wrapper = document.createElement("div");
wrapper.className = "projector-markdown";
fetch(data.source_url)
.then(res => res.text())
.then(text => {
// Simple markdown parsing (use marked.js in production)
.then((res) => res.text())
.then((text) => {
wrapper.innerHTML = parseMarkdown(text);
})
.catch(() => {
wrapper.innerHTML = '<p>Failed to load markdown</p>';
wrapper.innerHTML = "<p>Failed to load markdown</p>";
});
clearContent(container);
@ -330,10 +301,11 @@ function loadMarkdown(container, data) {
// Load Iframe
function loadIframe(container, data) {
const iframe = document.createElement('iframe');
iframe.className = 'projector-iframe';
const iframe = document.createElement("iframe");
iframe.className = "projector-iframe";
iframe.src = data.source_url;
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
iframe.allow =
"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture";
iframe.allowFullscreen = true;
clearContent(container);
@ -344,10 +316,10 @@ function loadIframe(container, data) {
// Load Generic
function loadGeneric(container, data) {
const wrapper = document.createElement('div');
wrapper.style.textAlign = 'center';
wrapper.style.padding = '40px';
wrapper.style.color = '#888';
const wrapper = document.createElement("div");
wrapper.style.textAlign = "center";
wrapper.style.padding = "40px";
wrapper.style.color = "#888";
wrapper.innerHTML = `
<div style="font-size: 64px; margin-bottom: 20px;">📁</div>
@ -365,7 +337,7 @@ function loadGeneric(container, data) {
// Show Error
function showError(message) {
const content = document.getElementById('projector-content');
const content = document.getElementById("projector-content");
content.innerHTML = `
<div class="projector-error">
<span class="projector-error-icon"></span>
@ -376,8 +348,8 @@ function showError(message) {
// Clear Content
function clearContent(container) {
const loading = document.getElementById('projector-loading');
container.innerHTML = '';
const loading = document.getElementById("projector-loading");
container.innerHTML = "";
container.appendChild(loading);
}
@ -386,39 +358,37 @@ function showControls(type) {
hideAllControls();
const controls = document.getElementById(`${type}-controls`);
if (controls) {
controls.classList.remove('hidden');
controls.classList.remove("hidden");
}
}
function hideAllControls() {
document.getElementById('media-controls')?.classList.add('hidden');
document.getElementById('slide-controls')?.classList.add('hidden');
document.getElementById('image-controls')?.classList.add('hidden');
document.getElementById('code-controls')?.classList.add('hidden');
document.getElementById("media-controls")?.classList.add("hidden");
document.getElementById("slide-controls")?.classList.add("hidden");
document.getElementById("image-controls")?.classList.add("hidden");
document.getElementById("code-controls")?.classList.add("hidden");
}
// Close Projector
function closeProjector() {
const overlay = document.getElementById('projector-overlay');
overlay.classList.add('hidden');
const overlay = document.getElementById("projector-overlay");
overlay.classList.add("hidden");
projectorState.isOpen = false;
// Stop any playing media
const media = getMediaElement();
if (media) {
media.pause();
media.src = '';
media.src = "";
}
// Clear content
const content = document.getElementById('projector-content');
const loading = document.getElementById('projector-loading');
content.innerHTML = '';
const content = document.getElementById("projector-content");
const loading = document.getElementById("projector-loading");
content.innerHTML = "";
content.appendChild(loading);
}
function closeProjectorOnOverlay(event) {
if (event.target.id === 'projector-overlay') {
if (event.target.id === "projector-overlay") {
closeProjector();
}
}
@ -461,7 +431,7 @@ function setVolume(value) {
if (media) {
media.volume = value / 100;
projectorState.isMuted = value === 0;
document.getElementById('mute-btn').textContent = value === 0 ? '🔇' : '🔊';
document.getElementById("mute-btn").textContent = value === 0 ? "🔇" : "🔊";
}
}
@ -470,7 +440,7 @@ function toggleMute() {
if (media) {
media.muted = !media.muted;
projectorState.isMuted = media.muted;
document.getElementById('mute-btn').textContent = media.muted ? '🔇' : '🔊';
document.getElementById("mute-btn").textContent = media.muted ? "🔇" : "🔊";
}
}
@ -479,7 +449,7 @@ function toggleLoop() {
if (media) {
media.loop = !media.loop;
projectorState.isLooping = media.loop;
document.getElementById('loop-btn').classList.toggle('active', media.loop);
document.getElementById("loop-btn").classList.toggle("active", media.loop);
}
}
@ -494,7 +464,7 @@ function updateProgress() {
const media = getMediaElement();
if (media && media.duration) {
const progress = (media.currentTime / media.duration) * 100;
document.getElementById('progress-bar').value = progress;
document.getElementById("progress-bar").value = progress;
}
}
@ -503,15 +473,16 @@ function updateTimeDisplay() {
if (media) {
const current = formatTime(media.currentTime);
const duration = formatTime(media.duration || 0);
document.getElementById('time-display').textContent = `${current} / ${duration}`;
document.getElementById("time-display").textContent =
`${current} / ${duration}`;
}
}
function formatTime(seconds) {
if (isNaN(seconds)) return '0:00';
if (isNaN(seconds)) return "0:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
// Slide/Page Controls
@ -538,7 +509,7 @@ function goToSlide(num) {
}
function updateSlide() {
const slideContent = document.getElementById('slide-content');
const slideContent = document.getElementById("slide-content");
if (slideContent) {
slideContent.src = `${projectorState.source}?slide=${projectorState.currentSlide}`;
}
@ -546,9 +517,9 @@ function updateSlide() {
}
function updateSlideInfo() {
document.getElementById('slide-info').textContent =
document.getElementById("slide-info").textContent =
`Slide ${projectorState.currentSlide} of ${projectorState.totalSlides}`;
document.getElementById('slide-input').value = projectorState.currentSlide;
document.getElementById("slide-input").value = projectorState.currentSlide;
}
// Image Controls
@ -567,18 +538,17 @@ function nextImage() {
}
function updateImage() {
// Implementation for image galleries
updateImageInfo();
}
function updateImageInfo() {
document.getElementById('image-info').textContent =
document.getElementById("image-info").textContent =
`${projectorState.currentImage + 1} of ${projectorState.totalImages}`;
}
function rotateImage() {
projectorState.rotation = (projectorState.rotation + 90) % 360;
const img = document.getElementById('projector-img');
const img = document.getElementById("projector-img");
if (img) {
img.style.transform = `rotate(${projectorState.rotation}deg) scale(${projectorState.zoom / 100})`;
}
@ -587,11 +557,11 @@ function rotateImage() {
function fitToScreen() {
projectorState.zoom = 100;
projectorState.rotation = 0;
const img = document.getElementById('projector-img');
const img = document.getElementById("projector-img");
if (img) {
img.style.transform = 'none';
img.style.transform = "none";
}
document.getElementById('zoom-level').textContent = '100%';
document.getElementById("zoom-level").textContent = "100%";
}
// Zoom Controls
@ -606,8 +576,8 @@ function zoomOut() {
}
function applyZoom() {
const img = document.getElementById('projector-img');
const slideContainer = document.getElementById('slide-container');
const img = document.getElementById("projector-img");
const slideContainer = document.getElementById("slide-container");
if (img) {
img.style.transform = `rotate(${projectorState.rotation}deg) scale(${projectorState.zoom / 100})`;
@ -616,171 +586,181 @@ function applyZoom() {
slideContainer.style.transform = `scale(${projectorState.zoom / 100})`;
}
document.getElementById('zoom-level').textContent = `${projectorState.zoom}%`;
document.getElementById("zoom-level").textContent = `${projectorState.zoom}%`;
}
// Code Controls
function toggleLineNumbers() {
projectorState.lineNumbers = !projectorState.lineNumbers;
const container = document.getElementById('code-container');
const container = document.getElementById("code-container");
if (container) {
container.classList.toggle('line-numbers', projectorState.lineNumbers);
container.classList.toggle("line-numbers", projectorState.lineNumbers);
}
}
function toggleWordWrap() {
projectorState.wordWrap = !projectorState.wordWrap;
const container = document.getElementById('code-container');
const container = document.getElementById("code-container");
if (container) {
container.style.whiteSpace = projectorState.wordWrap ? 'pre-wrap' : 'pre';
container.style.whiteSpace = projectorState.wordWrap ? "pre-wrap" : "pre";
}
}
function setCodeTheme(theme) {
const container = document.getElementById('code-container');
const container = document.getElementById("code-container");
if (container) {
container.className = `projector-code ${projectorState.lineNumbers ? 'line-numbers' : ''} theme-${theme}`;
container.className = `projector-code ${projectorState.lineNumbers ? "line-numbers" : ""} theme-${theme}`;
}
}
function copyCode() {
const code = document.querySelector('.projector-code code');
const code = document.querySelector(".projector-code code");
if (code) {
navigator.clipboard.writeText(code.textContent).then(() => {
// Show feedback
const btn = document.querySelector('.code-controls .control-btn:last-child');
const btn = document.querySelector(
".code-controls .control-btn:last-child",
);
const originalText = btn.textContent;
btn.textContent = '✅';
setTimeout(() => btn.textContent = originalText, 2000);
btn.textContent = "✅";
setTimeout(() => (btn.textContent = originalText), 2000);
});
}
}
// Fullscreen
function toggleFullscreen() {
const container = document.querySelector('.projector-container');
const icon = document.getElementById('fullscreen-icon');
const container = document.querySelector(".projector-container");
const icon = document.getElementById("fullscreen-icon");
if (!document.fullscreenElement) {
container.requestFullscreen().then(() => {
container.classList.add('fullscreen');
icon.textContent = '⛶';
}).catch(() => {});
container
.requestFullscreen()
.then(() => {
container.classList.add("fullscreen");
icon.textContent = "⛶";
})
.catch(() => {});
} else {
document.exitFullscreen().then(() => {
container.classList.remove('fullscreen');
icon.textContent = '⛶';
}).catch(() => {});
document
.exitFullscreen()
.then(() => {
container.classList.remove("fullscreen");
icon.textContent = "⛶";
})
.catch(() => {});
}
}
// Download
function downloadContent() {
const link = document.createElement('a');
const link = document.createElement("a");
link.href = projectorState.source;
link.download = '';
link.download = "";
link.click();
}
// Share
function shareContent() {
if (navigator.share) {
navigator.share({
title: document.getElementById('projector-title').textContent,
url: projectorState.source
}).catch(() => {});
navigator
.share({
title: document.getElementById("projector-title").textContent,
url: projectorState.source,
})
.catch(() => {});
} else {
navigator.clipboard.writeText(window.location.origin + projectorState.source).then(() => {
alert('Link copied to clipboard!');
navigator.clipboard
.writeText(window.location.origin + projectorState.source)
.then(() => {
alert("Link copied to clipboard!");
});
}
}
// Keyboard shortcuts for projector
document.addEventListener('keydown', (e) => {
document.addEventListener("keydown", (e) => {
if (!projectorState.isOpen) return;
switch (e.key) {
case 'Escape':
case "Escape":
closeProjector();
break;
case ' ':
case " ":
e.preventDefault();
togglePlayPause();
break;
case 'ArrowLeft':
if (projectorState.contentType === 'Video' || projectorState.contentType === 'Audio') {
case "ArrowLeft":
if (
projectorState.contentType === "Video" ||
projectorState.contentType === "Audio"
) {
mediaSeekBack();
} else {
prevSlide();
}
break;
case 'ArrowRight':
if (projectorState.contentType === 'Video' || projectorState.contentType === 'Audio') {
case "ArrowRight":
if (
projectorState.contentType === "Video" ||
projectorState.contentType === "Audio"
) {
mediaSeekForward();
} else {
nextSlide();
}
break;
case 'f':
case "f":
toggleFullscreen();
break;
case 'm':
case "m":
toggleMute();
break;
case '+':
case '=':
case "+":
case "=":
zoomIn();
break;
case '-':
case "-":
zoomOut();
break;
}
});
// Helper Functions
function escapeHtml(text) {
const div = document.createElement('div');
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function parseMarkdown(text) {
// Simple markdown parsing - use marked.js for full support
return text
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*)\*/gim, '<em>$1</em>')
.replace(/`([^`]+)`/gim, '<code>$1</code>')
.replace(/\n/gim, '<br>');
.replace(/^### (.*$)/gim, "<h3>$1</h3>")
.replace(/^## (.*$)/gim, "<h2>$1</h2>")
.replace(/^# (.*$)/gim, "<h1>$1</h1>")
.replace(/\*\*(.*)\*\*/gim, "<strong>$1</strong>")
.replace(/\*(.*)\*/gim, "<em>$1</em>")
.replace(/`([^`]+)`/gim, "<code>$1</code>")
.replace(/\n/gim, "<br>");
}
// Listen for play messages from WebSocket
if (window.htmx) {
htmx.on('htmx:wsMessage', function(event) {
htmx.on("htmx:wsMessage", function (event) {
try {
const data = JSON.parse(event.detail.message);
if (data.type === 'play') {
if (data.type === "play") {
openProjector(data.data);
} else if (data.type === 'player_command') {
} else if (data.type === "player_command") {
switch (data.command) {
case 'stop':
case "stop":
closeProjector();
break;
case 'pause':
case "pause":
const media = getMediaElement();
if (media) media.pause();
break;
case 'resume':
case "resume":
const mediaR = getMediaElement();
if (mediaR) mediaR.play();
break;
}
}
} catch (e) {
// Not a projector message
}
} catch (e) {}
});
}

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,143 +1,131 @@
/**
* 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');
const modal = document.getElementById("composeModal");
if (modal) {
modal.classList.remove('hidden');
modal.classList.remove('minimized');
modal.classList.remove("hidden");
modal.classList.remove("minimized");
if (replyTo) {
document.getElementById('composeTo').value = replyTo;
document.getElementById("composeTo").value = replyTo;
}
}
}
function closeCompose() {
const modal = document.getElementById('composeModal');
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 = '';
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');
const modal = document.getElementById("composeModal");
if (modal) {
modal.classList.toggle('minimized');
modal.classList.toggle("minimized");
}
}
function toggleCcBcc() {
const ccBcc = document.getElementById('ccBccFields');
const ccBcc = document.getElementById("ccBccFields");
if (ccBcc) {
ccBcc.classList.toggle('hidden');
ccBcc.classList.toggle("hidden");
}
}
// Schedule Functions
function toggleScheduleMenu() {
const menu = document.getElementById('scheduleMenu');
const menu = document.getElementById("scheduleMenu");
if (menu) {
menu.classList.toggle('hidden');
menu.classList.toggle("hidden");
}
}
function scheduleSend(when) {
console.log('Scheduling send for:', when);
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);
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');
const checked = document.querySelectorAll(".email-checkbox:checked");
const bulkActions = document.getElementById("bulkActions");
if (bulkActions) {
bulkActions.style.display = checked.length > 0 ? 'flex' : 'none';
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);
console.log("Using template:", name);
closeTemplatesModal();
}
function useSignature(name) {
console.log('Using signature:', name);
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');
const checked = document.querySelectorAll(".email-checkbox:checked");
if (confirm(`Delete ${checked.length} email(s)?`)) {
console.log('Deleting', checked.length, 'emails');
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');
const attachmentList = document.getElementById("attachmentList");
if (attachmentList && files.length > 0) {
for (const file of files) {
const item = document.createElement('div');
item.className = 'attachment-item';
const item = document.createElement("div");
item.className = "attachment-item";
item.innerHTML = `
<span>${file.name}</span>
<button type="button" onclick="this.parentElement.remove()">×</button>
@ -147,27 +135,22 @@ function handleAttachment(input) {
}
}
// Keyboard Shortcuts
document.addEventListener('keydown', function(e) {
// Escape closes modals
if (e.key === 'Escape') {
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") {
closeCompose();
closeTemplatesModal();
closeSignaturesModal();
closeRulesModal();
}
// Ctrl+N for new email
if (e.ctrlKey && e.key === 'n') {
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>
`;
}
// =============================================================================