diff --git a/src/main.rs b/src/main.rs index a4ce31e..8689ae9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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..."), + } } diff --git a/src/ui_server/mod.rs b/src/ui_server/mod.rs index b40f126..a0fea05 100644 --- a/src/ui_server/mod.rs +++ b/src/ui_server/mod.rs @@ -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) -> (StatusCode, axum::Json) { match state.health_check().await { true => ( @@ -80,7 +97,6 @@ async fn health(State(state): State) -> (StatusCode, axum::Json (StatusCode, axum::Json) { ( StatusCode::OK, @@ -91,12 +107,9 @@ async fn api_health() -> (StatusCode, axum::Json) { ) } -/// Extract app context from Referer header or path fn extract_app_context(headers: &axum::http::HeaderMap, path: &str) -> Option { - // 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 Option, 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 { 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, @@ -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 { Router::new().fallback(any(ws_proxy)) } -/// Create apps proxy router - proxies /apps/* to botserver fn create_apps_router() -> Router { - 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 { - 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, suite_path: &PathBuf) -> Router { + 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) } diff --git a/ui/suite/chat/chat.js b/ui/suite/chat/chat.js index 1977646..3c9eff2 100644 --- a/ui/suite/chat/chat.js +++ b/ui/suite/chat/chat.js @@ -1,355 +1,327 @@ -/* Chat module JavaScript - including projector component */ - -// Projector State let projectorState = { - isOpen: false, - contentType: null, - source: null, - options: {}, - currentSlide: 1, - totalSlides: 1, - currentImage: 0, - totalImages: 1, - zoom: 100, - rotation: 0, - isPlaying: false, - isLooping: false, - isMuted: false, - lineNumbers: true, - wordWrap: false + isOpen: false, + contentType: null, + source: null, + options: {}, + currentSlide: 1, + totalSlides: 1, + currentImage: 0, + totalImages: 1, + zoom: 100, + rotation: 0, + isPlaying: false, + isLooping: false, + isMuted: false, + lineNumbers: true, + 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'); + 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 || {}, + }; + title.textContent = data.title || "Content Viewer"; - // Reset state - projectorState = { - ...projectorState, - isOpen: true, - contentType: data.content_type, - source: data.source_url, - options: data.options || {} - }; - - // 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': 'πŸ“ƒ' - }; - icon.textContent = icons[data.content_type] || 'πŸ“'; - - // Show loading - loading.classList.remove('hidden'); - hideAllControls(); - - // Show overlay - overlay.classList.remove('hidden'); - - // Load content based on type - loadContent(data); + const icons = { + Video: "🎬", + Audio: "🎡", + Image: "πŸ–ΌοΈ", + Pdf: "πŸ“„", + Presentation: "πŸ“Š", + Code: "πŸ’»", + Spreadsheet: "πŸ“ˆ", + Markdown: "πŸ“", + Html: "🌐", + Document: "πŸ“ƒ", + }; + icon.textContent = icons[data.content_type] || "πŸ“"; + loading.classList.remove("hidden"); + hideAllControls(); + 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'); + setTimeout(() => { + loading.classList.add("hidden"); - switch (data.content_type) { - case 'Video': - loadVideo(content, data); - break; - case 'Audio': - loadAudio(content, data); - break; - case 'Image': - loadImage(content, data); - break; - case 'Pdf': - loadPdf(content, data); - break; - case 'Presentation': - loadPresentation(content, data); - break; - case 'Code': - loadCode(content, data); - break; - case 'Markdown': - loadMarkdown(content, data); - break; - case 'Iframe': - case 'Html': - loadIframe(content, data); - break; - default: - loadGeneric(content, data); - } - }, 300); + switch (data.content_type) { + case "Video": + loadVideo(content, data); + break; + case "Audio": + loadAudio(content, data); + break; + case "Image": + loadImage(content, data); + break; + case "Pdf": + loadPdf(content, data); + break; + case "Presentation": + loadPresentation(content, data); + break; + case "Code": + loadCode(content, data); + break; + case "Markdown": + loadMarkdown(content, data); + break; + case "Iframe": + case "Html": + loadIframe(content, data); + break; + default: + loadGeneric(content, data); + } + }, 300); } // 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'; - 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; + 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'); - updateTimeDisplay(); - }); + video.addEventListener("loadedmetadata", () => { + loading.classList.add("hidden"); + updateTimeDisplay(); + }); - video.addEventListener('timeupdate', () => { - updateProgress(); - updateTimeDisplay(); - }); + video.addEventListener("timeupdate", () => { + updateProgress(); + updateTimeDisplay(); + }); - video.addEventListener('play', () => { - projectorState.isPlaying = true; - document.getElementById('play-pause-btn').textContent = '⏸️'; - }); + video.addEventListener("play", () => { + projectorState.isPlaying = true; + document.getElementById("play-pause-btn").textContent = "⏸️"; + }); - video.addEventListener('pause', () => { - projectorState.isPlaying = false; - document.getElementById('play-pause-btn').textContent = '▢️'; - }); + video.addEventListener("pause", () => { + projectorState.isPlaying = false; + document.getElementById("play-pause-btn").textContent = "▢️"; + }); - video.addEventListener('ended', () => { - if (!projectorState.isLooping) { - projectorState.isPlaying = false; - document.getElementById('play-pause-btn').textContent = '▢️'; - } - }); - - // Clear and add video - clearContent(container); - container.appendChild(video); - - // Show media controls - showControls('media'); + video.addEventListener("ended", () => { + if (!projectorState.isLooping) { + projectorState.isPlaying = false; + document.getElementById("play-pause-btn").textContent = "▢️"; + } + }); + clearContent(container); + container.appendChild(video); + 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'; - wrapper.appendChild(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'; - audio.src = data.source_url; - audio.autoplay = data.options?.autoplay || false; - audio.loop = data.options?.loop_content || false; + 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', () => { - updateProgress(); - updateTimeDisplay(); - }); - audio.addEventListener('play', () => { - projectorState.isPlaying = true; - document.getElementById('play-pause-btn').textContent = '⏸️'; - }); - audio.addEventListener('pause', () => { - projectorState.isPlaying = false; - document.getElementById('play-pause-btn').textContent = '▢️'; - }); + audio.addEventListener("loadedmetadata", () => updateTimeDisplay()); + audio.addEventListener("timeupdate", () => { + updateProgress(); + updateTimeDisplay(); + }); + audio.addEventListener("play", () => { + projectorState.isPlaying = true; + document.getElementById("play-pause-btn").textContent = "⏸️"; + }); + audio.addEventListener("pause", () => { + projectorState.isPlaying = false; + document.getElementById("play-pause-btn").textContent = "▢️"; + }); - wrapper.appendChild(audio); + wrapper.appendChild(audio); - clearContent(container); - container.appendChild(wrapper); + clearContent(container); + container.appendChild(wrapper); - showControls('media'); + showControls("media"); } // Load Image function loadImage(container, data) { - const img = document.createElement('img'); - img.className = 'projector-image'; - img.src = data.source_url; - img.alt = data.title || 'Image'; - img.id = 'projector-img'; + const img = document.createElement("img"); + img.className = "projector-image"; + img.src = data.source_url; + 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); + 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'); - updateImageInfo(); + showControls("image"); + updateImageInfo(); } // Load PDF function loadPdf(container, data) { - const iframe = document.createElement('iframe'); - iframe.className = 'projector-pdf'; - iframe.src = `/static/pdfjs/web/viewer.html?file=${encodeURIComponent(data.source_url)}`; + 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); + 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'; - slideImg.src = `${data.source_url}?slide=1`; + const slideImg = document.createElement("img"); + slideImg.className = "slide-content"; + slideImg.id = "slide-content"; + slideImg.src = `${data.source_url}?slide=1`; - slideContainer.appendChild(slideImg); - wrapper.appendChild(slideContainer); + slideContainer.appendChild(slideImg); + wrapper.appendChild(slideContainer); - clearContent(container); - container.appendChild(wrapper); + clearContent(container); + container.appendChild(wrapper); - showControls('slide'); - updateSlideInfo(); + showControls("slide"); + updateSlideInfo(); } // Load Code function loadCode(container, data) { - const wrapper = document.createElement('div'); - wrapper.className = 'projector-code'; - wrapper.id = 'code-container'; - if (projectorState.lineNumbers) { - wrapper.classList.add('line-numbers'); - } + const wrapper = document.createElement("div"); + wrapper.className = "projector-code"; + wrapper.id = "code-container"; + if (projectorState.lineNumbers) { + 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 => - `${escapeHtml(line)}` - ).join('\n'); - code.innerHTML = lines; + fetch(data.source_url) + .then((res) => res.text()) + .then((text) => { + const lines = text + .split("\n") + .map((line) => `${escapeHtml(line)}`) + .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'; - }); + if (window.Prism) { + Prism.highlightElement(code); + } + }) + .catch(() => { + code.textContent = "Failed to load code"; + }); - pre.appendChild(code); - wrapper.appendChild(pre); + pre.appendChild(code); + wrapper.appendChild(pre); - clearContent(container); - container.appendChild(wrapper); + 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) - wrapper.innerHTML = parseMarkdown(text); - }) - .catch(() => { - wrapper.innerHTML = '

Failed to load markdown

'; - }); + fetch(data.source_url) + .then((res) => res.text()) + .then((text) => { + wrapper.innerHTML = parseMarkdown(text); + }) + .catch(() => { + wrapper.innerHTML = "

Failed to load markdown

"; + }); - clearContent(container); - container.appendChild(wrapper); + clearContent(container); + container.appendChild(wrapper); - hideAllControls(); + hideAllControls(); } // Load Iframe function loadIframe(container, data) { - 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.allowFullscreen = true; + 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.allowFullscreen = true; - clearContent(container); - container.appendChild(iframe); + clearContent(container); + container.appendChild(iframe); - hideAllControls(); + hideAllControls(); } // 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 = ` + wrapper.innerHTML = `
πŸ“
Cannot preview this file type
@@ -357,16 +329,16 @@ function loadGeneric(container, data) { `; - clearContent(container); - container.appendChild(wrapper); + clearContent(container); + container.appendChild(wrapper); - hideAllControls(); + hideAllControls(); } // Show Error function showError(message) { - const content = document.getElementById('projector-content'); - content.innerHTML = ` + const content = document.getElementById("projector-content"); + content.innerHTML = `
❌ ${message} @@ -376,411 +348,419 @@ function showError(message) { // Clear Content function clearContent(container) { - const loading = document.getElementById('projector-loading'); - container.innerHTML = ''; - container.appendChild(loading); + const loading = document.getElementById("projector-loading"); + container.innerHTML = ""; + container.appendChild(loading); } // Show/Hide Controls function showControls(type) { - hideAllControls(); - const controls = document.getElementById(`${type}-controls`); - if (controls) { - controls.classList.remove('hidden'); - } + hideAllControls(); + const controls = document.getElementById(`${type}-controls`); + if (controls) { + 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'); - projectorState.isOpen = false; + 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 = ''; - } + const media = getMediaElement(); + if (media) { + media.pause(); + media.src = ""; + } - // Clear content - const content = document.getElementById('projector-content'); - const loading = document.getElementById('projector-loading'); - content.innerHTML = ''; - content.appendChild(loading); + 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') { - closeProjector(); - } + if (event.target.id === "projector-overlay") { + closeProjector(); + } } // Media Controls function togglePlayPause() { - const media = getMediaElement(); - if (media) { - if (media.paused) { - media.play(); - } else { - media.pause(); - } + const media = getMediaElement(); + if (media) { + if (media.paused) { + media.play(); + } else { + media.pause(); } + } } function mediaSeekBack() { - const media = getMediaElement(); - if (media) { - media.currentTime = Math.max(0, media.currentTime - 10); - } + const media = getMediaElement(); + if (media) { + media.currentTime = Math.max(0, media.currentTime - 10); + } } function mediaSeekForward() { - const media = getMediaElement(); - if (media) { - media.currentTime = Math.min(media.duration, media.currentTime + 10); - } + const media = getMediaElement(); + if (media) { + media.currentTime = Math.min(media.duration, media.currentTime + 10); + } } function seekTo(percent) { - const media = getMediaElement(); - if (media && media.duration) { - media.currentTime = (percent / 100) * media.duration; - } + const media = getMediaElement(); + if (media && media.duration) { + media.currentTime = (percent / 100) * media.duration; + } } function setVolume(value) { - const media = getMediaElement(); - if (media) { - media.volume = value / 100; - projectorState.isMuted = value === 0; - document.getElementById('mute-btn').textContent = value === 0 ? 'πŸ”‡' : 'πŸ”Š'; - } + const media = getMediaElement(); + if (media) { + media.volume = value / 100; + projectorState.isMuted = value === 0; + document.getElementById("mute-btn").textContent = value === 0 ? "πŸ”‡" : "πŸ”Š"; + } } function toggleMute() { - const media = getMediaElement(); - if (media) { - media.muted = !media.muted; - projectorState.isMuted = media.muted; - document.getElementById('mute-btn').textContent = media.muted ? 'πŸ”‡' : 'πŸ”Š'; - } + const media = getMediaElement(); + if (media) { + media.muted = !media.muted; + projectorState.isMuted = media.muted; + document.getElementById("mute-btn").textContent = media.muted ? "πŸ”‡" : "πŸ”Š"; + } } function toggleLoop() { - const media = getMediaElement(); - if (media) { - media.loop = !media.loop; - projectorState.isLooping = media.loop; - document.getElementById('loop-btn').classList.toggle('active', media.loop); - } + const media = getMediaElement(); + if (media) { + media.loop = !media.loop; + projectorState.isLooping = media.loop; + document.getElementById("loop-btn").classList.toggle("active", media.loop); + } } function setPlaybackSpeed(speed) { - const media = getMediaElement(); - if (media) { - media.playbackRate = parseFloat(speed); - } + const media = getMediaElement(); + if (media) { + media.playbackRate = parseFloat(speed); + } } function updateProgress() { - const media = getMediaElement(); - if (media && media.duration) { - const progress = (media.currentTime / media.duration) * 100; - document.getElementById('progress-bar').value = progress; - } + const media = getMediaElement(); + if (media && media.duration) { + const progress = (media.currentTime / media.duration) * 100; + document.getElementById("progress-bar").value = progress; + } } function updateTimeDisplay() { - const media = getMediaElement(); - if (media) { - const current = formatTime(media.currentTime); - const duration = formatTime(media.duration || 0); - document.getElementById('time-display').textContent = `${current} / ${duration}`; - } + const media = getMediaElement(); + if (media) { + const current = formatTime(media.currentTime); + const duration = formatTime(media.duration || 0); + document.getElementById("time-display").textContent = + `${current} / ${duration}`; + } } function formatTime(seconds) { - 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')}`; + 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")}`; } // Slide/Page Controls function prevSlide() { - if (projectorState.currentSlide > 1) { - projectorState.currentSlide--; - updateSlide(); - } + if (projectorState.currentSlide > 1) { + projectorState.currentSlide--; + updateSlide(); + } } function nextSlide() { - if (projectorState.currentSlide < projectorState.totalSlides) { - projectorState.currentSlide++; - updateSlide(); - } + if (projectorState.currentSlide < projectorState.totalSlides) { + projectorState.currentSlide++; + updateSlide(); + } } function goToSlide(num) { - const slide = parseInt(num); - if (slide >= 1 && slide <= projectorState.totalSlides) { - projectorState.currentSlide = slide; - updateSlide(); - } + const slide = parseInt(num); + if (slide >= 1 && slide <= projectorState.totalSlides) { + projectorState.currentSlide = slide; + updateSlide(); + } } function updateSlide() { - const slideContent = document.getElementById('slide-content'); - if (slideContent) { - slideContent.src = `${projectorState.source}?slide=${projectorState.currentSlide}`; - } - updateSlideInfo(); + const slideContent = document.getElementById("slide-content"); + if (slideContent) { + slideContent.src = `${projectorState.source}?slide=${projectorState.currentSlide}`; + } + updateSlideInfo(); } function updateSlideInfo() { - document.getElementById('slide-info').textContent = - `Slide ${projectorState.currentSlide} of ${projectorState.totalSlides}`; - document.getElementById('slide-input').value = projectorState.currentSlide; + document.getElementById("slide-info").textContent = + `Slide ${projectorState.currentSlide} of ${projectorState.totalSlides}`; + document.getElementById("slide-input").value = projectorState.currentSlide; } // Image Controls function prevImage() { - if (projectorState.currentImage > 0) { - projectorState.currentImage--; - updateImage(); - } + if (projectorState.currentImage > 0) { + projectorState.currentImage--; + updateImage(); + } } function nextImage() { - if (projectorState.currentImage < projectorState.totalImages - 1) { - projectorState.currentImage++; - updateImage(); - } + if (projectorState.currentImage < projectorState.totalImages - 1) { + projectorState.currentImage++; + updateImage(); + } } function updateImage() { - // Implementation for image galleries - updateImageInfo(); + updateImageInfo(); } function updateImageInfo() { - document.getElementById('image-info').textContent = - `${projectorState.currentImage + 1} of ${projectorState.totalImages}`; + 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'); - if (img) { - img.style.transform = `rotate(${projectorState.rotation}deg) scale(${projectorState.zoom / 100})`; - } + projectorState.rotation = (projectorState.rotation + 90) % 360; + const img = document.getElementById("projector-img"); + if (img) { + img.style.transform = `rotate(${projectorState.rotation}deg) scale(${projectorState.zoom / 100})`; + } } function fitToScreen() { - projectorState.zoom = 100; - projectorState.rotation = 0; - const img = document.getElementById('projector-img'); - if (img) { - img.style.transform = 'none'; - } - document.getElementById('zoom-level').textContent = '100%'; + projectorState.zoom = 100; + projectorState.rotation = 0; + const img = document.getElementById("projector-img"); + if (img) { + img.style.transform = "none"; + } + document.getElementById("zoom-level").textContent = "100%"; } // Zoom Controls function zoomIn() { - projectorState.zoom = Math.min(300, projectorState.zoom + 25); - applyZoom(); + projectorState.zoom = Math.min(300, projectorState.zoom + 25); + applyZoom(); } function zoomOut() { - projectorState.zoom = Math.max(25, projectorState.zoom - 25); - applyZoom(); + projectorState.zoom = Math.max(25, projectorState.zoom - 25); + applyZoom(); } 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})`; - } - if (slideContainer) { - slideContainer.style.transform = `scale(${projectorState.zoom / 100})`; - } + if (img) { + img.style.transform = `rotate(${projectorState.rotation}deg) scale(${projectorState.zoom / 100})`; + } + if (slideContainer) { + 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'); - if (container) { - container.classList.toggle('line-numbers', projectorState.lineNumbers); - } + projectorState.lineNumbers = !projectorState.lineNumbers; + const container = document.getElementById("code-container"); + if (container) { + container.classList.toggle("line-numbers", projectorState.lineNumbers); + } } function toggleWordWrap() { - projectorState.wordWrap = !projectorState.wordWrap; - const container = document.getElementById('code-container'); - if (container) { - container.style.whiteSpace = projectorState.wordWrap ? 'pre-wrap' : 'pre'; - } + projectorState.wordWrap = !projectorState.wordWrap; + const container = document.getElementById("code-container"); + if (container) { + container.style.whiteSpace = projectorState.wordWrap ? "pre-wrap" : "pre"; + } } function setCodeTheme(theme) { - const container = document.getElementById('code-container'); - if (container) { - container.className = `projector-code ${projectorState.lineNumbers ? 'line-numbers' : ''} theme-${theme}`; - } + const container = document.getElementById("code-container"); + if (container) { + container.className = `projector-code ${projectorState.lineNumbers ? "line-numbers" : ""} theme-${theme}`; + } } function copyCode() { - 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 originalText = btn.textContent; - btn.textContent = 'βœ…'; - setTimeout(() => btn.textContent = originalText, 2000); - }); - } + const code = document.querySelector(".projector-code code"); + if (code) { + navigator.clipboard.writeText(code.textContent).then(() => { + const btn = document.querySelector( + ".code-controls .control-btn:last-child", + ); + const originalText = btn.textContent; + 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(() => {}); - } else { - document.exitFullscreen().then(() => { - container.classList.remove('fullscreen'); - icon.textContent = 'β›Ά'; - }).catch(() => {}); - } + if (!document.fullscreenElement) { + container + .requestFullscreen() + .then(() => { + container.classList.add("fullscreen"); + icon.textContent = "β›Ά"; + }) + .catch(() => {}); + } else { + document + .exitFullscreen() + .then(() => { + container.classList.remove("fullscreen"); + icon.textContent = "β›Ά"; + }) + .catch(() => {}); + } } // Download function downloadContent() { - const link = document.createElement('a'); - link.href = projectorState.source; - link.download = ''; - link.click(); + const link = document.createElement("a"); + link.href = projectorState.source; + link.download = ""; + link.click(); } // Share function shareContent() { - if (navigator.share) { - 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!'); - }); - } + if (navigator.share) { + 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!"); + }); + } } // Keyboard shortcuts for projector -document.addEventListener('keydown', (e) => { - if (!projectorState.isOpen) return; +document.addEventListener("keydown", (e) => { + if (!projectorState.isOpen) return; - switch (e.key) { - case 'Escape': - closeProjector(); - break; - case ' ': - e.preventDefault(); - togglePlayPause(); - break; - case 'ArrowLeft': - if (projectorState.contentType === 'Video' || projectorState.contentType === 'Audio') { - mediaSeekBack(); - } else { - prevSlide(); - } - break; - case 'ArrowRight': - if (projectorState.contentType === 'Video' || projectorState.contentType === 'Audio') { - mediaSeekForward(); - } else { - nextSlide(); - } - break; - case 'f': - toggleFullscreen(); - break; - case 'm': - toggleMute(); - break; - case '+': - case '=': - zoomIn(); - break; - case '-': - zoomOut(); - break; - } + switch (e.key) { + case "Escape": + closeProjector(); + break; + case " ": + e.preventDefault(); + togglePlayPause(); + break; + case "ArrowLeft": + if ( + projectorState.contentType === "Video" || + projectorState.contentType === "Audio" + ) { + mediaSeekBack(); + } else { + prevSlide(); + } + break; + case "ArrowRight": + if ( + projectorState.contentType === "Video" || + projectorState.contentType === "Audio" + ) { + mediaSeekForward(); + } else { + nextSlide(); + } + break; + case "f": + toggleFullscreen(); + break; + case "m": + toggleMute(); + break; + case "+": + case "=": + zoomIn(); + break; + case "-": + zoomOut(); + break; + } }); - -// Helper Functions function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + 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, '

$1

') - .replace(/^## (.*$)/gim, '

$1

') - .replace(/^# (.*$)/gim, '

$1

') - .replace(/\*\*(.*)\*\*/gim, '$1') - .replace(/\*(.*)\*/gim, '$1') - .replace(/`([^`]+)`/gim, '$1') - .replace(/\n/gim, '
'); + return text + .replace(/^### (.*$)/gim, "

$1

") + .replace(/^## (.*$)/gim, "

$1

") + .replace(/^# (.*$)/gim, "

$1

") + .replace(/\*\*(.*)\*\*/gim, "$1") + .replace(/\*(.*)\*/gim, "$1") + .replace(/`([^`]+)`/gim, "$1") + .replace(/\n/gim, "
"); } - -// Listen for play messages from WebSocket if (window.htmx) { - htmx.on('htmx:wsMessage', function(event) { - try { - const data = JSON.parse(event.detail.message); - if (data.type === 'play') { - openProjector(data.data); - } else if (data.type === 'player_command') { - switch (data.command) { - case 'stop': - closeProjector(); - break; - case 'pause': - const media = getMediaElement(); - if (media) media.pause(); - break; - case 'resume': - const mediaR = getMediaElement(); - if (mediaR) mediaR.play(); - break; - } - } - } catch (e) { - // Not a projector message + htmx.on("htmx:wsMessage", function (event) { + try { + const data = JSON.parse(event.detail.message); + if (data.type === "play") { + openProjector(data.data); + } else if (data.type === "player_command") { + switch (data.command) { + case "stop": + closeProjector(); + break; + case "pause": + const media = getMediaElement(); + if (media) media.pause(); + break; + case "resume": + const mediaR = getMediaElement(); + if (mediaR) mediaR.play(); + break; } - }); + } + } catch (e) {} + }); } diff --git a/ui/suite/js/base.js b/ui/suite/js/base.js index 76b70a2..2315de7 100644 --- a/ui/suite/js/base.js +++ b/ui/suite/js/base.js @@ -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.", diff --git a/ui/suite/mail/mail.js b/ui/suite/mail/mail.js index cc175b9..b554c8d 100644 --- a/ui/suite/mail/mail.js +++ b/ui/suite/mail/mail.js @@ -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 = ` ${file.name} `; - 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); + }); }); diff --git a/ui/suite/tasks/autotask.js b/ui/suite/tasks/autotask.js index 8df662f..ae7d229 100644 --- a/ui/suite/tasks/autotask.js +++ b/ui/suite/tasks/autotask.js @@ -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 = ` + + `; + 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) => ` +
+ ${i + 1} + ${step.action || step.type || "Action"} + ${step.target || step.description || ""} +
+ `, + ) + .join(""); + + resultDiv.innerHTML = ` +
+
+

${plan.name || "Compiled Plan"}

+ ${plan.priority || "medium"} +
+ ${plan.description ? `

${plan.description}

` : ""} +
${stepsHtml}
+
+ + + +
+
+ `; } // =============================================================================