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 log::info;
use std::net::SocketAddr;
mod shared; mod shared;
mod ui_server; mod ui_server;
#[tokio::main] fn init_logging() {
async fn main() -> std::io::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
env_logger::init(); .format_timestamp_millis()
info!("BotUI starting..."); .init();
info!("Starting web UI server..."); }
let app = ui_server::configure_router(); fn get_port() -> u16 {
std::env::var("BOTUI_PORT")
let port: u16 = std::env::var("BOTUI_PORT")
.ok() .ok()
.and_then(|p| p.parse().ok()) .and_then(|p| p.parse().ok())
.unwrap_or(3000); .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); #[tokio::main]
async fn main() -> anyhow::Result<()> {
axum::serve(listener, app).await 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::{ use axum::{
body::Body, body::Body,
extract::{ extract::{
@ -20,15 +16,38 @@ use std::{fs, path::PathBuf};
use tokio_tungstenite::{ use tokio_tungstenite::{
connect_async_tls_with_config, tungstenite::protocol::Message as TungsteniteMessage, connect_async_tls_with_config, tungstenite::protocol::Message as TungsteniteMessage,
}; };
use tower_http::services::ServeDir;
use crate::shared::AppState; 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 { pub async fn index() -> impl IntoResponse {
serve_suite().await serve_suite().await
} }
/// Handler for minimal UI
pub async fn serve_minimal() -> impl IntoResponse { pub async fn serve_minimal() -> impl IntoResponse {
match fs::read_to_string("ui/minimal/index.html") { match fs::read_to_string("ui/minimal/index.html") {
Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(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 { pub async fn serve_suite() -> impl IntoResponse {
match fs::read_to_string("ui/suite/index.html") { match fs::read_to_string("ui/suite/index.html") {
Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(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>) { async fn health(State(state): State<AppState>) -> (StatusCode, axum::Json<serde_json::Value>) {
match state.health_check().await { match state.health_check().await {
true => ( 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>) { async fn api_health() -> (StatusCode, axum::Json<serde_json::Value>) {
( (
StatusCode::OK, 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> { 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 Some(referer) = headers.get("referer") {
if let Ok(referer_str) = referer.to_str() { if let Ok(referer_str) = referer.to_str() {
// Match /apps/{app_name}/ pattern
if let Some(start) = referer_str.find("/apps/") { if let Some(start) = referer_str.find("/apps/") {
let after_apps = &referer_str[start + 6..]; let after_apps = &referer_str[start + 6..];
if let Some(end) = after_apps.find('/') { 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/") { if path.starts_with("/apps/") {
let after_apps = &path[6..]; let after_apps = &path[6..];
if let Some(end) = after_apps.find('/') { if let Some(end) = after_apps.find('/') {
@ -119,7 +131,6 @@ fn extract_app_context(headers: &axum::http::HeaderMap, path: &str) -> Option<St
None None
} }
/// Proxy API requests to botserver
async fn proxy_api( async fn proxy_api(
State(state): State<AppState>, State(state): State<AppState>,
original_uri: OriginalUri, original_uri: OriginalUri,
@ -133,7 +144,6 @@ async fn proxy_api(
let method = req.method().clone(); let method = req.method().clone();
let headers = req.headers().clone(); let headers = req.headers().clone();
// Extract app context from request
let app_context = extract_app_context(&headers, path); let app_context = extract_app_context(&headers, path);
let target_url = format!("{}{}{}", state.client.base_url(), path, query); let target_url = format!("{}{}{}", state.client.base_url(), path, query);
@ -142,14 +152,12 @@ async fn proxy_api(
method, path, target_url, app_context method, path, target_url, app_context
); );
// Build the proxied request with self-signed cert support
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true) .danger_accept_invalid_certs(true)
.build() .build()
.unwrap_or_else(|_| reqwest::Client::new()); .unwrap_or_else(|_| reqwest::Client::new());
let mut proxy_req = client.request(method.clone(), &target_url); let mut proxy_req = client.request(method.clone(), &target_url);
// Copy headers (excluding host)
for (name, value) in headers.iter() { for (name, value) in headers.iter() {
if name != "host" { if name != "host" {
if let Ok(v) = value.to_str() { 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 { if let Some(app) = app_context {
proxy_req = proxy_req.header("X-App-Context", app); 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 { let body_bytes = match axum::body::to_bytes(req.into_body(), usize::MAX).await {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(e) => { Err(e) => {
@ -179,7 +185,6 @@ async fn proxy_api(
proxy_req = proxy_req.body(body_bytes.to_vec()); proxy_req = proxy_req.body(body_bytes.to_vec());
} }
// Execute the request
match proxy_req.send().await { match proxy_req.send().await {
Ok(resp) => { Ok(resp) => {
let status = resp.status(); let status = resp.status();
@ -189,7 +194,6 @@ async fn proxy_api(
Ok(body) => { Ok(body) => {
let mut response = Response::builder().status(status); let mut response = Response::builder().status(status);
// Copy response headers
for (name, value) in headers.iter() { for (name, value) in headers.iter() {
response = response.header(name, value); response = response.header(name, value);
} }
@ -220,32 +224,18 @@ async fn proxy_api(
} }
} }
/// Create API proxy router
fn create_api_router() -> Router<AppState> { fn create_api_router() -> Router<AppState> {
Router::new() Router::new()
.route("/health", get(api_health)) .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)) .fallback(any(proxy_api))
} }
/// WebSocket query parameters
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct WsQuery { struct WsQuery {
session_id: String, session_id: String,
user_id: String, user_id: String,
} }
/// WebSocket proxy handler
async fn ws_proxy( async fn ws_proxy(
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
State(state): State<AppState>, State(state): State<AppState>,
@ -254,7 +244,6 @@ async fn ws_proxy(
ws.on_upgrade(move |socket| handle_ws_proxy(socket, state, params)) 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) { async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQuery) {
let backend_url = format!( let backend_url = format!(
"{}/ws?session_id={}&user_id={}", "{}/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); info!("Proxying WebSocket to: {}", backend_url);
// Create TLS connector that accepts self-signed certs
let tls_connector = native_tls::TlsConnector::builder() let tls_connector = native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(true) .danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(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); let connector = tokio_tungstenite::Connector::NativeTls(tls_connector);
// Connect to backend WebSocket
let backend_result = let backend_result =
connect_async_tls_with_config(&backend_url, None, false, Some(connector)).await; 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"); info!("Connected to backend WebSocket");
// Split both sockets
let (mut client_tx, mut client_rx) = client_socket.split(); let (mut client_tx, mut client_rx) = client_socket.split();
let (mut backend_tx, mut backend_rx) = backend_socket.split(); let (mut backend_tx, mut backend_rx) = backend_socket.split();
// Forward messages from client to backend
let client_to_backend = async { let client_to_backend = async {
while let Some(msg) = client_rx.next().await { while let Some(msg) = client_rx.next().await {
match msg { 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 { let backend_to_client = async {
while let Some(msg) = backend_rx.next().await { while let Some(msg) = backend_rx.next().await {
match msg { 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! { tokio::select! {
_ = client_to_backend => info!("Client connection closed"), _ = client_to_backend => info!("Client connection closed"),
_ = backend_to_client => info!("Backend connection closed"), _ = backend_to_client => info!("Backend connection closed"),
} }
} }
/// Create WebSocket proxy router
fn create_ws_router() -> Router<AppState> { fn create_ws_router() -> Router<AppState> {
Router::new().fallback(any(ws_proxy)) Router::new().fallback(any(ws_proxy))
} }
/// Create apps proxy router - proxies /apps/* to botserver
fn create_apps_router() -> Router<AppState> { fn create_apps_router() -> Router<AppState> {
Router::new() Router::new().fallback(any(proxy_api))
// 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))
} }
/// Create UI HTMX proxy router (for HTML fragment endpoints)
fn create_ui_router() -> Router<AppState> { fn create_ui_router() -> Router<AppState> {
Router::new() Router::new().fallback(any(proxy_api))
// Email UI endpoints }
.route("/email/accounts", any(proxy_api))
.route("/email/list", any(proxy_api)) fn add_static_routes(router: Router<AppState>, suite_path: &PathBuf) -> Router<AppState> {
.route("/email/folders", any(proxy_api)) let mut r = router;
.route("/email/compose", any(proxy_api))
.route("/email/labels", any(proxy_api)) for dir in SUITE_DIRS {
.route("/email/templates", any(proxy_api)) let path = suite_path.join(dir);
.route("/email/signatures", any(proxy_api)) r = r
.route("/email/rules", any(proxy_api)) .nest_service(&format!("/suite/{}", dir), ServeDir::new(path.clone()))
.route("/email/search", any(proxy_api)) .nest_service(&format!("/{}", dir), ServeDir::new(path));
.route("/email/auto-responder", any(proxy_api)) }
.route("/email/{id}", any(proxy_api))
.route("/email/{id}/delete", any(proxy_api)) r
// 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))
} }
/// Configure and return the main router
pub fn configure_router() -> Router { pub fn configure_router() -> Router {
let suite_path = PathBuf::from("./ui/suite"); let suite_path = PathBuf::from("./ui/suite");
let _minimal_path = PathBuf::from("./ui/minimal");
let state = AppState::new(); let state = AppState::new();
Router::new() let mut router = Router::new()
// Health check endpoints
.route("/health", get(health)) .route("/health", get(health))
// API proxy routes
.nest("/api", create_api_router()) .nest("/api", create_api_router())
// UI HTMX proxy routes (for /ui/* endpoints that return HTML fragments)
.nest("/ui", create_ui_router()) .nest("/ui", create_ui_router())
// WebSocket proxy routes
.nest("/ws", create_ws_router()) .nest("/ws", create_ws_router())
// Apps proxy routes - proxy /apps/* to botserver (which serves from site_path)
.nest("/apps", create_apps_router()) .nest("/apps", create_apps_router())
// UI routes
.route("/", get(index)) .route("/", get(index))
.route("/minimal", get(serve_minimal)) .route("/minimal", get(serve_minimal))
.route("/suite", get(serve_suite)) .route("/suite", get(serve_suite));
// Suite static assets (when accessing /suite/*)
.nest_service( router = add_static_routes(router, &suite_path);
"/suite/js",
tower_http::services::ServeDir::new(suite_path.join("js")), router
)
.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)
.fallback_service( .fallback_service(
tower_http::services::ServeDir::new(suite_path.clone()).fallback( ServeDir::new(suite_path.clone())
tower_http::services::ServeDir::new(suite_path) .fallback(ServeDir::new(suite_path).append_index_html_on_directories(true)),
.append_index_html_on_directories(true),
),
) )
.with_state(state) .with_state(state)
} }

View file

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

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 appsBtn = document.getElementById("apps-btn");
const appsDropdown = document.getElementById("apps-dropdown"); const appsDropdown = document.getElementById("apps-dropdown");
const settingsBtn = document.getElementById("settings-btn"); const settingsBtn = document.getElementById("settings-btn");
const settingsPanel = document.getElementById("settings-panel"); const settingsPanel = document.getElementById("settings-panel");
// Apps Menu Toggle
if (appsBtn) { if (appsBtn) {
appsBtn.addEventListener("click", (e) => { appsBtn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
@ -20,7 +12,6 @@ if (appsBtn) {
}); });
} }
// Settings Panel Toggle
if (settingsBtn) { if (settingsBtn) {
settingsBtn.addEventListener("click", (e) => { settingsBtn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
@ -30,7 +21,6 @@ if (settingsBtn) {
}); });
} }
// Close dropdowns when clicking outside
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
if ( if (
appsDropdown && appsDropdown &&
@ -50,7 +40,6 @@ document.addEventListener("click", (e) => {
} }
}); });
// Escape key closes dropdowns
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
if (appsDropdown) appsDropdown.classList.remove("show"); if (appsDropdown) appsDropdown.classList.remove("show");
@ -60,7 +49,6 @@ document.addEventListener("keydown", (e) => {
} }
}); });
// Alt+key shortcuts for navigation
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.altKey && !e.ctrlKey && !e.shiftKey) { if (e.altKey && !e.ctrlKey && !e.shiftKey) {
const shortcuts = { const shortcuts = {
@ -95,7 +83,6 @@ document.addEventListener("keydown", (e) => {
} }
}); });
// Update active app on HTMX swap
document.body.addEventListener("htmx:afterSwap", (e) => { document.body.addEventListener("htmx:afterSwap", (e) => {
if (e.detail.target.id === "main-content") { if (e.detail.target.id === "main-content") {
const hash = window.location.hash || "#chat"; 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 themeOptions = document.querySelectorAll(".theme-option");
const savedTheme = localStorage.getItem("gb-theme") || "sentient"; const savedTheme = localStorage.getItem("gb-theme") || "sentient";
document.body.setAttribute("data-theme", savedTheme); document.body.setAttribute("data-theme", savedTheme);
@ -115,10 +100,8 @@ document
.querySelector(`.theme-option[data-theme="${savedTheme}"]`) .querySelector(`.theme-option[data-theme="${savedTheme}"]`)
?.classList.add("active"); ?.classList.add("active");
// Update theme-color meta tag based on theme
function updateThemeColor(theme) { function updateThemeColor(theme) {
const themeColors = { const themeColors = {
// Core Themes
dark: "#3b82f6", dark: "#3b82f6",
light: "#3b82f6", light: "#3b82f6",
blue: "#0ea5e9", blue: "#0ea5e9",
@ -126,7 +109,6 @@ function updateThemeColor(theme) {
green: "#22c55e", green: "#22c55e",
orange: "#f97316", orange: "#f97316",
sentient: "#d4f505", sentient: "#d4f505",
// Retro Themes
cyberpunk: "#ff00ff", cyberpunk: "#ff00ff",
retrowave: "#ff6b9d", retrowave: "#ff6b9d",
vapordream: "#a29bfe", vapordream: "#a29bfe",
@ -134,7 +116,6 @@ function updateThemeColor(theme) {
arcadeflash: "#ffff00", arcadeflash: "#ffff00",
discofever: "#ff1493", discofever: "#ff1493",
grungeera: "#8b4513", grungeera: "#8b4513",
// Classic Themes
jazzage: "#d4af37", jazzage: "#d4af37",
mellowgold: "#daa520", mellowgold: "#daa520",
midcenturymod: "#e07b39", midcenturymod: "#e07b39",
@ -142,7 +123,6 @@ function updateThemeColor(theme) {
saturdaycartoons: "#ff6347", saturdaycartoons: "#ff6347",
seasidepostcard: "#20b2aa", seasidepostcard: "#20b2aa",
typewriter: "#2f2f2f", typewriter: "#2f2f2f",
// Tech Themes
"3dbevel": "#0000ff", "3dbevel": "#0000ff",
xeroxui: "#4a86cf", xeroxui: "#4a86cf",
xtreegold: "#ffff00", xtreegold: "#ffff00",
@ -165,7 +145,6 @@ themeOptions.forEach((option) => {
}); });
}); });
// Global theme setter function (can be called from settings or elsewhere)
window.setTheme = function (theme) { window.setTheme = function (theme) {
document.body.setAttribute("data-theme", theme); document.body.setAttribute("data-theme", theme);
localStorage.setItem("gb-theme", theme); localStorage.setItem("gb-theme", theme);
@ -175,14 +154,12 @@ window.setTheme = function (theme) {
updateThemeColor(theme); updateThemeColor(theme);
}; };
// Quick Settings Toggle
function toggleQuickSetting(el) { function toggleQuickSetting(el) {
el.classList.toggle("active"); el.classList.toggle("active");
const setting = el.id.replace("toggle-", ""); const setting = el.id.replace("toggle-", "");
localStorage.setItem(`gb-${setting}`, el.classList.contains("active")); localStorage.setItem(`gb-${setting}`, el.classList.contains("active"));
} }
// Load quick toggle states
["notifications", "sound", "compact"].forEach((setting) => { ["notifications", "sound", "compact"].forEach((setting) => {
const saved = localStorage.getItem(`gb-${setting}`); const saved = localStorage.getItem(`gb-${setting}`);
const toggle = document.getElementById(`toggle-${setting}`); const toggle = document.getElementById(`toggle-${setting}`);
@ -191,7 +168,6 @@ function toggleQuickSetting(el) {
} }
}); });
// Show keyboard shortcuts notification
function showKeyboardShortcuts() { function showKeyboardShortcuts() {
window.showNotification( window.showNotification(
"Alt+1-9,0 for apps, Alt+A Admin, Alt+M Monitoring, Alt+S Settings, Alt+, quick settings", "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) { function announceToScreenReader(message) {
const liveRegion = document.getElementById("aria-live"); const liveRegion = document.getElementById("aria-live");
if (liveRegion) { if (liveRegion) {
liveRegion.textContent = message; liveRegion.textContent = message;
// Clear after announcement
setTimeout(() => { setTimeout(() => {
liveRegion.textContent = ""; liveRegion.textContent = "";
}, 1000); }, 1000);
} }
} }
// HTMX accessibility hooks
document.body.addEventListener("htmx:beforeRequest", function (e) { document.body.addEventListener("htmx:beforeRequest", function (e) {
const target = e.detail.target; const target = e.detail.target;
if (target && target.id === "main-content") { if (target && target.id === "main-content") {
@ -225,7 +198,6 @@ document.body.addEventListener("htmx:afterSwap", function (e) {
const target = e.detail.target; const target = e.detail.target;
if (target && target.id === "main-content") { if (target && target.id === "main-content") {
target.setAttribute("aria-busy", "false"); target.setAttribute("aria-busy", "false");
// Focus management: move focus to main content after navigation
target.focus(); target.focus();
announceToScreenReader("Content loaded"); announceToScreenReader("Content loaded");
} }
@ -239,7 +211,6 @@ document.body.addEventListener("htmx:responseError", function (e) {
announceToScreenReader("Error loading content. Please try again."); announceToScreenReader("Error loading content. Please try again.");
}); });
// Keyboard navigation for apps grid
document.addEventListener("keydown", function (e) { document.addEventListener("keydown", function (e) {
const appsGrid = document.querySelector(".apps-grid"); const appsGrid = document.querySelector(".apps-grid");
if (!appsGrid || !appsGrid.closest(".show")) return; if (!appsGrid || !appsGrid.closest(".show")) return;
@ -252,7 +223,7 @@ document.addEventListener("keydown", function (e) {
if (currentIndex === -1) return; if (currentIndex === -1) return;
let newIndex = currentIndex; let newIndex = currentIndex;
const columns = 3; // Grid has 3 columns on desktop const columns = 3;
switch (e.key) { switch (e.key) {
case "ArrowRight": case "ArrowRight":
@ -283,7 +254,6 @@ document.addEventListener("keydown", function (e) {
} }
}); });
// Notification System
window.showNotification = function (message, type = "info", duration = 5000) { window.showNotification = function (message, type = "info", duration = 5000) {
const container = document.getElementById("notifications"); const container = document.getElementById("notifications");
if (!container) return; if (!container) return;
@ -302,7 +272,6 @@ window.showNotification = function (message, type = "info", duration = 5000) {
} }
}; };
// Global HTMX error handling with retry mechanism
const htmxRetryConfig = { const htmxRetryConfig = {
maxRetries: 3, maxRetries: 3,
retryDelay: 1000, retryDelay: 1000,
@ -357,12 +326,10 @@ window.retryLastRequest = function (btn) {
if (retryCallback && window[retryCallback]) { if (retryCallback && window[retryCallback]) {
window[retryCallback](); window[retryCallback]();
} else { } else {
// Try to re-trigger HTMX request
const triggers = target.querySelectorAll("[hx-get], [hx-post]"); const triggers = target.querySelectorAll("[hx-get], [hx-post]");
if (triggers.length > 0) { if (triggers.length > 0) {
htmx.trigger(triggers[0], "htmx:trigger"); htmx.trigger(triggers[0], "htmx:trigger");
} else { } else {
// Reload the current app
const activeApp = document.querySelector(".app-item.active"); const activeApp = document.querySelector(".app-item.active");
if (activeApp) { if (activeApp) {
activeApp.click(); activeApp.click();
@ -371,7 +338,6 @@ window.retryLastRequest = function (btn) {
} }
}; };
// Handle HTMX errors globally
document.body.addEventListener("htmx:responseError", function (e) { document.body.addEventListener("htmx:responseError", function (e) {
const target = e.detail.target; const target = e.detail.target;
const xhr = e.detail.xhr; const xhr = e.detail.xhr;
@ -379,7 +345,6 @@ document.body.addEventListener("htmx:responseError", function (e) {
let currentRetries = htmxRetryConfig.retryCount.get(retryKey) || 0; let currentRetries = htmxRetryConfig.retryCount.get(retryKey) || 0;
// Auto-retry for network errors (status 0) or server errors (5xx)
if ( if (
(xhr.status === 0 || xhr.status >= 500) && (xhr.status === 0 || xhr.status >= 500) &&
currentRetries < htmxRetryConfig.maxRetries currentRetries < htmxRetryConfig.maxRetries
@ -397,7 +362,6 @@ document.body.addEventListener("htmx:responseError", function (e) {
htmx.trigger(e.detail.elt, "htmx:trigger"); htmx.trigger(e.detail.elt, "htmx:trigger");
}, delay); }, delay);
} else { } else {
// Max retries reached or client error - show error state
htmxRetryConfig.retryCount.delete(retryKey); htmxRetryConfig.retryCount.delete(retryKey);
let errorMessage = "We couldn't load the content."; 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) { document.body.addEventListener("htmx:afterRequest", function (e) {
if (e.detail.successful) { if (e.detail.successful) {
const retryKey = getRetryKey(e.detail.elt); 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) { document.body.addEventListener("htmx:timeout", function (e) {
window.showNotification( window.showNotification(
"Request timed out. Please try again.", "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) { document.body.addEventListener("htmx:sendError", function (e) {
window.showNotification( window.showNotification(
"Network error. Please check your connection.", "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) { function openCompose(replyTo = null, forward = null) {
const modal = document.getElementById('composeModal'); const modal = document.getElementById("composeModal");
if (modal) { if (modal) {
modal.classList.remove('hidden'); modal.classList.remove("hidden");
modal.classList.remove('minimized'); modal.classList.remove("minimized");
if (replyTo) { if (replyTo) {
document.getElementById('composeTo').value = replyTo; document.getElementById("composeTo").value = replyTo;
} }
} }
} }
function closeCompose() { function closeCompose() {
const modal = document.getElementById('composeModal'); const modal = document.getElementById("composeModal");
if (modal) { if (modal) {
modal.classList.add('hidden'); modal.classList.add("hidden");
// Clear form document.getElementById("composeTo").value = "";
document.getElementById('composeTo').value = ''; document.getElementById("composeCc").value = "";
document.getElementById('composeCc').value = ''; document.getElementById("composeBcc").value = "";
document.getElementById('composeBcc').value = ''; document.getElementById("composeSubject").value = "";
document.getElementById('composeSubject').value = ''; document.getElementById("composeBody").value = "";
document.getElementById('composeBody').value = '';
} }
} }
function minimizeCompose() { function minimizeCompose() {
const modal = document.getElementById('composeModal'); const modal = document.getElementById("composeModal");
if (modal) { if (modal) {
modal.classList.toggle('minimized'); modal.classList.toggle("minimized");
} }
} }
function toggleCcBcc() { function toggleCcBcc() {
const ccBcc = document.getElementById('ccBccFields'); const ccBcc = document.getElementById("ccBccFields");
if (ccBcc) { if (ccBcc) {
ccBcc.classList.toggle('hidden'); ccBcc.classList.toggle("hidden");
} }
} }
// Schedule Functions
function toggleScheduleMenu() { function toggleScheduleMenu() {
const menu = document.getElementById('scheduleMenu'); const menu = document.getElementById("scheduleMenu");
if (menu) { if (menu) {
menu.classList.toggle('hidden'); menu.classList.toggle("hidden");
} }
} }
function scheduleSend(when) { function scheduleSend(when) {
console.log('Scheduling send for:', when); console.log("Scheduling send for:", when);
toggleScheduleMenu(); toggleScheduleMenu();
} }
// Selection Functions
function toggleSelectAll() { function toggleSelectAll() {
const selectAll = document.getElementById('selectAll'); const selectAll = document.getElementById("selectAll");
const checkboxes = document.querySelectorAll('.email-checkbox'); const checkboxes = document.querySelectorAll(".email-checkbox");
checkboxes.forEach(cb => cb.checked = selectAll.checked); checkboxes.forEach((cb) => (cb.checked = selectAll.checked));
updateBulkActions(); updateBulkActions();
} }
function updateBulkActions() { function updateBulkActions() {
const checked = document.querySelectorAll('.email-checkbox:checked'); const checked = document.querySelectorAll(".email-checkbox:checked");
const bulkActions = document.getElementById('bulkActions'); const bulkActions = document.getElementById("bulkActions");
if (bulkActions) { if (bulkActions) {
bulkActions.style.display = checked.length > 0 ? 'flex' : 'none'; bulkActions.style.display = checked.length > 0 ? "flex" : "none";
} }
} }
// Modal Functions
function openTemplatesModal() { function openTemplatesModal() {
const modal = document.getElementById('templatesModal'); const modal = document.getElementById("templatesModal");
if (modal) modal.classList.remove('hidden'); if (modal) modal.classList.remove("hidden");
} }
function closeTemplatesModal() { function closeTemplatesModal() {
const modal = document.getElementById('templatesModal'); const modal = document.getElementById("templatesModal");
if (modal) modal.classList.add('hidden'); if (modal) modal.classList.add("hidden");
} }
function openSignaturesModal() { function openSignaturesModal() {
const modal = document.getElementById('signaturesModal'); const modal = document.getElementById("signaturesModal");
if (modal) modal.classList.remove('hidden'); if (modal) modal.classList.remove("hidden");
} }
function closeSignaturesModal() { function closeSignaturesModal() {
const modal = document.getElementById('signaturesModal'); const modal = document.getElementById("signaturesModal");
if (modal) modal.classList.add('hidden'); if (modal) modal.classList.add("hidden");
} }
function openRulesModal() { function openRulesModal() {
const modal = document.getElementById('rulesModal'); const modal = document.getElementById("rulesModal");
if (modal) modal.classList.remove('hidden'); if (modal) modal.classList.remove("hidden");
} }
function closeRulesModal() { function closeRulesModal() {
const modal = document.getElementById('rulesModal'); const modal = document.getElementById("rulesModal");
if (modal) modal.classList.add('hidden'); if (modal) modal.classList.add("hidden");
} }
function useTemplate(name) { function useTemplate(name) {
console.log('Using template:', name); console.log("Using template:", name);
closeTemplatesModal(); closeTemplatesModal();
} }
function useSignature(name) { function useSignature(name) {
console.log('Using signature:', name); console.log("Using signature:", name);
closeSignaturesModal(); closeSignaturesModal();
} }
// Bulk Actions
function archiveSelected() { function archiveSelected() {
const checked = document.querySelectorAll('.email-checkbox:checked'); const checked = document.querySelectorAll(".email-checkbox:checked");
console.log('Archiving', checked.length, 'emails'); console.log("Archiving", checked.length, "emails");
} }
function deleteSelected() { function deleteSelected() {
const checked = document.querySelectorAll('.email-checkbox:checked'); const checked = document.querySelectorAll(".email-checkbox:checked");
if (confirm(`Delete ${checked.length} email(s)?`)) { if (confirm(`Delete ${checked.length} email(s)?`)) {
console.log('Deleting', checked.length, 'emails'); console.log("Deleting", checked.length, "emails");
} }
} }
function markSelectedRead() { function markSelectedRead() {
const checked = document.querySelectorAll('.email-checkbox:checked'); const checked = document.querySelectorAll(".email-checkbox:checked");
console.log('Marking', checked.length, 'emails as read'); console.log("Marking", checked.length, "emails as read");
} }
// File Attachment
function handleAttachment(input) { function handleAttachment(input) {
const files = input.files; const files = input.files;
const attachmentList = document.getElementById('attachmentList'); const attachmentList = document.getElementById("attachmentList");
if (attachmentList && files.length > 0) { if (attachmentList && files.length > 0) {
for (const file of files) { for (const file of files) {
const item = document.createElement('div'); const item = document.createElement("div");
item.className = 'attachment-item'; item.className = "attachment-item";
item.innerHTML = ` item.innerHTML = `
<span>${file.name}</span> <span>${file.name}</span>
<button type="button" onclick="this.parentElement.remove()">×</button> <button type="button" onclick="this.parentElement.remove()">×</button>
@ -147,27 +135,22 @@ function handleAttachment(input) {
} }
} }
// Keyboard Shortcuts document.addEventListener("keydown", function (e) {
document.addEventListener('keydown', function(e) { if (e.key === "Escape") {
// Escape closes modals
if (e.key === 'Escape') {
closeCompose(); closeCompose();
closeTemplatesModal(); closeTemplatesModal();
closeSignaturesModal(); closeSignaturesModal();
closeRulesModal(); closeRulesModal();
} }
// Ctrl+N for new email if (e.ctrlKey && e.key === "n") {
if (e.ctrlKey && e.key === 'n') {
e.preventDefault(); e.preventDefault();
openCompose(); openCompose();
} }
}); });
// Initialize document.addEventListener("DOMContentLoaded", function () {
document.addEventListener('DOMContentLoaded', function() { document.querySelectorAll(".email-checkbox").forEach((cb) => {
// Add change listeners to checkboxes cb.addEventListener("change", updateBulkActions);
document.querySelectorAll('.email-checkbox').forEach(cb => {
cb.addEventListener('change', updateBulkActions);
}); });
}); });

View file

@ -373,8 +373,121 @@ function discardPlan() {
} }
function editPlan() { function editPlan() {
// TODO: Implement plan editor if (!AutoTaskState.compiledPlan) {
showToast("Plan editor coming soon!", "info"); 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>
`;
} }
// ============================================================================= // =============================================================================