refactor: eliminate router duplication, graceful shutdown, data-driven static routes
Some checks failed
GBCI / build (push) Failing after 9s
Some checks failed
GBCI / build (push) Failing after 9s
This commit is contained in:
parent
f6fc423d48
commit
1a50680712
6 changed files with 829 additions and 966 deletions
73
src/main.rs
73
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..."),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
//! UI Server module for BotUI
|
||||
//!
|
||||
//! Serves the web UI (suite, minimal) and handles API proxying.
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{
|
||||
|
|
@ -20,15 +16,38 @@ use std::{fs, path::PathBuf};
|
|||
use tokio_tungstenite::{
|
||||
connect_async_tls_with_config, tungstenite::protocol::Message as TungsteniteMessage,
|
||||
};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::shared::AppState;
|
||||
|
||||
/// Serve the index page (suite UI)
|
||||
const SUITE_DIRS: &[&str] = &[
|
||||
"js",
|
||||
"css",
|
||||
"public",
|
||||
"drive",
|
||||
"chat",
|
||||
"mail",
|
||||
"tasks",
|
||||
"calendar",
|
||||
"meet",
|
||||
"paper",
|
||||
"research",
|
||||
"analytics",
|
||||
"monitoring",
|
||||
"admin",
|
||||
"auth",
|
||||
"settings",
|
||||
"sources",
|
||||
"attendant",
|
||||
"tools",
|
||||
"assets",
|
||||
"partials",
|
||||
];
|
||||
|
||||
pub async fn index() -> impl IntoResponse {
|
||||
serve_suite().await
|
||||
}
|
||||
|
||||
/// Handler for minimal UI
|
||||
pub async fn serve_minimal() -> impl IntoResponse {
|
||||
match fs::read_to_string("ui/minimal/index.html") {
|
||||
Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(html)),
|
||||
|
|
@ -43,7 +62,6 @@ pub async fn serve_minimal() -> impl IntoResponse {
|
|||
}
|
||||
}
|
||||
|
||||
/// Handler for suite UI
|
||||
pub async fn serve_suite() -> impl IntoResponse {
|
||||
match fs::read_to_string("ui/suite/index.html") {
|
||||
Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(html)),
|
||||
|
|
@ -58,7 +76,6 @@ pub async fn serve_suite() -> impl IntoResponse {
|
|||
}
|
||||
}
|
||||
|
||||
/// Health check endpoint - checks BotServer connectivity
|
||||
async fn health(State(state): State<AppState>) -> (StatusCode, axum::Json<serde_json::Value>) {
|
||||
match state.health_check().await {
|
||||
true => (
|
||||
|
|
@ -80,7 +97,6 @@ async fn health(State(state): State<AppState>) -> (StatusCode, axum::Json<serde_
|
|||
}
|
||||
}
|
||||
|
||||
/// API health check endpoint
|
||||
async fn api_health() -> (StatusCode, axum::Json<serde_json::Value>) {
|
||||
(
|
||||
StatusCode::OK,
|
||||
|
|
@ -91,12 +107,9 @@ async fn api_health() -> (StatusCode, axum::Json<serde_json::Value>) {
|
|||
)
|
||||
}
|
||||
|
||||
/// Extract app context from Referer header or path
|
||||
fn extract_app_context(headers: &axum::http::HeaderMap, path: &str) -> Option<String> {
|
||||
// Try to extract from Referer header first
|
||||
if let Some(referer) = headers.get("referer") {
|
||||
if let Ok(referer_str) = referer.to_str() {
|
||||
// Match /apps/{app_name}/ pattern
|
||||
if let Some(start) = referer_str.find("/apps/") {
|
||||
let after_apps = &referer_str[start + 6..];
|
||||
if let Some(end) = after_apps.find('/') {
|
||||
|
|
@ -108,7 +121,6 @@ fn extract_app_context(headers: &axum::http::HeaderMap, path: &str) -> Option<St
|
|||
}
|
||||
}
|
||||
|
||||
// Try to extract from path (for /apps/{app}/api/* routes)
|
||||
if path.starts_with("/apps/") {
|
||||
let after_apps = &path[6..];
|
||||
if let Some(end) = after_apps.find('/') {
|
||||
|
|
@ -119,7 +131,6 @@ fn extract_app_context(headers: &axum::http::HeaderMap, path: &str) -> Option<St
|
|||
None
|
||||
}
|
||||
|
||||
/// Proxy API requests to botserver
|
||||
async fn proxy_api(
|
||||
State(state): State<AppState>,
|
||||
original_uri: OriginalUri,
|
||||
|
|
@ -133,7 +144,6 @@ async fn proxy_api(
|
|||
let method = req.method().clone();
|
||||
let headers = req.headers().clone();
|
||||
|
||||
// Extract app context from request
|
||||
let app_context = extract_app_context(&headers, path);
|
||||
|
||||
let target_url = format!("{}{}{}", state.client.base_url(), path, query);
|
||||
|
|
@ -142,14 +152,12 @@ async fn proxy_api(
|
|||
method, path, target_url, app_context
|
||||
);
|
||||
|
||||
// Build the proxied request with self-signed cert support
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
let mut proxy_req = client.request(method.clone(), &target_url);
|
||||
|
||||
// Copy headers (excluding host)
|
||||
for (name, value) in headers.iter() {
|
||||
if name != "host" {
|
||||
if let Ok(v) = value.to_str() {
|
||||
|
|
@ -158,12 +166,10 @@ async fn proxy_api(
|
|||
}
|
||||
}
|
||||
|
||||
// Inject X-App-Context header if we detected an app
|
||||
if let Some(app) = app_context {
|
||||
proxy_req = proxy_req.header("X-App-Context", app);
|
||||
}
|
||||
|
||||
// Copy body for non-GET requests
|
||||
let body_bytes = match axum::body::to_bytes(req.into_body(), usize::MAX).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
|
|
@ -179,7 +185,6 @@ async fn proxy_api(
|
|||
proxy_req = proxy_req.body(body_bytes.to_vec());
|
||||
}
|
||||
|
||||
// Execute the request
|
||||
match proxy_req.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
|
|
@ -189,7 +194,6 @@ async fn proxy_api(
|
|||
Ok(body) => {
|
||||
let mut response = Response::builder().status(status);
|
||||
|
||||
// Copy response headers
|
||||
for (name, value) in headers.iter() {
|
||||
response = response.header(name, value);
|
||||
}
|
||||
|
|
@ -220,32 +224,18 @@ async fn proxy_api(
|
|||
}
|
||||
}
|
||||
|
||||
/// Create API proxy router
|
||||
fn create_api_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/health", get(api_health))
|
||||
.route("/chat", any(proxy_api))
|
||||
.route("/sessions", any(proxy_api))
|
||||
.route("/sessions/{id}", any(proxy_api))
|
||||
.route("/sessions/{id}/history", any(proxy_api))
|
||||
.route("/sessions/{id}/start", any(proxy_api))
|
||||
.route("/drive/files", any(proxy_api))
|
||||
.route("/drive/files/{path}", any(proxy_api))
|
||||
.route("/drive/upload", any(proxy_api))
|
||||
.route("/drive/download/{path}", any(proxy_api))
|
||||
.route("/tasks", any(proxy_api))
|
||||
.route("/tasks/{id}", any(proxy_api))
|
||||
.fallback(any(proxy_api))
|
||||
}
|
||||
|
||||
/// WebSocket query parameters
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WsQuery {
|
||||
session_id: String,
|
||||
user_id: String,
|
||||
}
|
||||
|
||||
/// WebSocket proxy handler
|
||||
async fn ws_proxy(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
|
|
@ -254,7 +244,6 @@ async fn ws_proxy(
|
|||
ws.on_upgrade(move |socket| handle_ws_proxy(socket, state, params))
|
||||
}
|
||||
|
||||
/// Handle WebSocket proxy connection
|
||||
async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQuery) {
|
||||
let backend_url = format!(
|
||||
"{}/ws?session_id={}&user_id={}",
|
||||
|
|
@ -269,7 +258,6 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
|
|||
|
||||
info!("Proxying WebSocket to: {}", backend_url);
|
||||
|
||||
// Create TLS connector that accepts self-signed certs
|
||||
let tls_connector = native_tls::TlsConnector::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
|
|
@ -278,7 +266,6 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
|
|||
|
||||
let connector = tokio_tungstenite::Connector::NativeTls(tls_connector);
|
||||
|
||||
// Connect to backend WebSocket
|
||||
let backend_result =
|
||||
connect_async_tls_with_config(&backend_url, None, false, Some(connector)).await;
|
||||
|
||||
|
|
@ -292,11 +279,9 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
|
|||
|
||||
info!("Connected to backend WebSocket");
|
||||
|
||||
// Split both sockets
|
||||
let (mut client_tx, mut client_rx) = client_socket.split();
|
||||
let (mut backend_tx, mut backend_rx) = backend_socket.split();
|
||||
|
||||
// Forward messages from client to backend
|
||||
let client_to_backend = async {
|
||||
while let Some(msg) = client_rx.next().await {
|
||||
match msg {
|
||||
|
|
@ -341,7 +326,6 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
|
|||
}
|
||||
};
|
||||
|
||||
// Forward messages from backend to client
|
||||
let backend_to_client = async {
|
||||
while let Some(msg) = backend_rx.next().await {
|
||||
match msg {
|
||||
|
|
@ -371,250 +355,57 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
|
|||
}
|
||||
};
|
||||
|
||||
// Run both forwarding tasks concurrently
|
||||
tokio::select! {
|
||||
_ = client_to_backend => info!("Client connection closed"),
|
||||
_ = backend_to_client => info!("Backend connection closed"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create WebSocket proxy router
|
||||
fn create_ws_router() -> Router<AppState> {
|
||||
Router::new().fallback(any(ws_proxy))
|
||||
}
|
||||
|
||||
/// Create apps proxy router - proxies /apps/* to botserver
|
||||
fn create_apps_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
// Proxy all /apps/* requests to botserver
|
||||
// botserver serves static files from site_path
|
||||
// API calls from apps also go through here with X-App-Context header
|
||||
.fallback(any(proxy_api))
|
||||
Router::new().fallback(any(proxy_api))
|
||||
}
|
||||
|
||||
/// Create UI HTMX proxy router (for HTML fragment endpoints)
|
||||
fn create_ui_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
// Email UI endpoints
|
||||
.route("/email/accounts", any(proxy_api))
|
||||
.route("/email/list", any(proxy_api))
|
||||
.route("/email/folders", any(proxy_api))
|
||||
.route("/email/compose", any(proxy_api))
|
||||
.route("/email/labels", any(proxy_api))
|
||||
.route("/email/templates", any(proxy_api))
|
||||
.route("/email/signatures", any(proxy_api))
|
||||
.route("/email/rules", any(proxy_api))
|
||||
.route("/email/search", any(proxy_api))
|
||||
.route("/email/auto-responder", any(proxy_api))
|
||||
.route("/email/{id}", any(proxy_api))
|
||||
.route("/email/{id}/delete", any(proxy_api))
|
||||
// Calendar UI endpoints
|
||||
.route("/calendar/list", any(proxy_api))
|
||||
.route("/calendar/upcoming", any(proxy_api))
|
||||
.route("/calendar/event/new", any(proxy_api))
|
||||
.route("/calendar/new", any(proxy_api))
|
||||
// Fallback for any other /ui/* routes
|
||||
.fallback(any(proxy_api))
|
||||
Router::new().fallback(any(proxy_api))
|
||||
}
|
||||
|
||||
fn add_static_routes(router: Router<AppState>, suite_path: &PathBuf) -> Router<AppState> {
|
||||
let mut r = router;
|
||||
|
||||
for dir in SUITE_DIRS {
|
||||
let path = suite_path.join(dir);
|
||||
r = r
|
||||
.nest_service(&format!("/suite/{}", dir), ServeDir::new(path.clone()))
|
||||
.nest_service(&format!("/{}", dir), ServeDir::new(path));
|
||||
}
|
||||
|
||||
r
|
||||
}
|
||||
|
||||
/// Configure and return the main router
|
||||
pub fn configure_router() -> Router {
|
||||
let suite_path = PathBuf::from("./ui/suite");
|
||||
let _minimal_path = PathBuf::from("./ui/minimal");
|
||||
let state = AppState::new();
|
||||
|
||||
Router::new()
|
||||
// Health check endpoints
|
||||
let mut router = Router::new()
|
||||
.route("/health", get(health))
|
||||
// API proxy routes
|
||||
.nest("/api", create_api_router())
|
||||
// UI HTMX proxy routes (for /ui/* endpoints that return HTML fragments)
|
||||
.nest("/ui", create_ui_router())
|
||||
// WebSocket proxy routes
|
||||
.nest("/ws", create_ws_router())
|
||||
// Apps proxy routes - proxy /apps/* to botserver (which serves from site_path)
|
||||
.nest("/apps", create_apps_router())
|
||||
// UI routes
|
||||
.route("/", get(index))
|
||||
.route("/minimal", get(serve_minimal))
|
||||
.route("/suite", get(serve_suite))
|
||||
// Suite static assets (when accessing /suite/*)
|
||||
.nest_service(
|
||||
"/suite/js",
|
||||
tower_http::services::ServeDir::new(suite_path.join("js")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/css",
|
||||
tower_http::services::ServeDir::new(suite_path.join("css")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/public",
|
||||
tower_http::services::ServeDir::new(suite_path.join("public")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/drive",
|
||||
tower_http::services::ServeDir::new(suite_path.join("drive")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/chat",
|
||||
tower_http::services::ServeDir::new(suite_path.join("chat")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/mail",
|
||||
tower_http::services::ServeDir::new(suite_path.join("mail")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/tasks",
|
||||
tower_http::services::ServeDir::new(suite_path.join("tasks")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/calendar",
|
||||
tower_http::services::ServeDir::new(suite_path.join("calendar")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/meet",
|
||||
tower_http::services::ServeDir::new(suite_path.join("meet")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/paper",
|
||||
tower_http::services::ServeDir::new(suite_path.join("paper")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/research",
|
||||
tower_http::services::ServeDir::new(suite_path.join("research")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/analytics",
|
||||
tower_http::services::ServeDir::new(suite_path.join("analytics")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/monitoring",
|
||||
tower_http::services::ServeDir::new(suite_path.join("monitoring")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/admin",
|
||||
tower_http::services::ServeDir::new(suite_path.join("admin")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/auth",
|
||||
tower_http::services::ServeDir::new(suite_path.join("auth")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/settings",
|
||||
tower_http::services::ServeDir::new(suite_path.join("settings")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/sources",
|
||||
tower_http::services::ServeDir::new(suite_path.join("sources")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/attendant",
|
||||
tower_http::services::ServeDir::new(suite_path.join("attendant")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/tools",
|
||||
tower_http::services::ServeDir::new(suite_path.join("tools")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/assets",
|
||||
tower_http::services::ServeDir::new(suite_path.join("assets")),
|
||||
)
|
||||
.nest_service(
|
||||
"/suite/partials",
|
||||
tower_http::services::ServeDir::new(suite_path.join("partials")),
|
||||
)
|
||||
// Legacy paths for backward compatibility (serve suite assets)
|
||||
.nest_service(
|
||||
"/js",
|
||||
tower_http::services::ServeDir::new(suite_path.join("js")),
|
||||
)
|
||||
.nest_service(
|
||||
"/css",
|
||||
tower_http::services::ServeDir::new(suite_path.join("css")),
|
||||
)
|
||||
.nest_service(
|
||||
"/public",
|
||||
tower_http::services::ServeDir::new(suite_path.join("public")),
|
||||
)
|
||||
.nest_service(
|
||||
"/drive",
|
||||
tower_http::services::ServeDir::new(suite_path.join("drive")),
|
||||
)
|
||||
.nest_service(
|
||||
"/chat",
|
||||
tower_http::services::ServeDir::new(suite_path.join("chat")),
|
||||
)
|
||||
.nest_service(
|
||||
"/mail",
|
||||
tower_http::services::ServeDir::new(suite_path.join("mail")),
|
||||
)
|
||||
.nest_service(
|
||||
"/tasks",
|
||||
tower_http::services::ServeDir::new(suite_path.join("tasks")),
|
||||
)
|
||||
// Additional app routes
|
||||
.nest_service(
|
||||
"/paper",
|
||||
tower_http::services::ServeDir::new(suite_path.join("paper")),
|
||||
)
|
||||
.nest_service(
|
||||
"/calendar",
|
||||
tower_http::services::ServeDir::new(suite_path.join("calendar")),
|
||||
)
|
||||
.nest_service(
|
||||
"/research",
|
||||
tower_http::services::ServeDir::new(suite_path.join("research")),
|
||||
)
|
||||
.nest_service(
|
||||
"/meet",
|
||||
tower_http::services::ServeDir::new(suite_path.join("meet")),
|
||||
)
|
||||
.nest_service(
|
||||
"/analytics",
|
||||
tower_http::services::ServeDir::new(suite_path.join("analytics")),
|
||||
)
|
||||
.nest_service(
|
||||
"/monitoring",
|
||||
tower_http::services::ServeDir::new(suite_path.join("monitoring")),
|
||||
)
|
||||
.nest_service(
|
||||
"/admin",
|
||||
tower_http::services::ServeDir::new(suite_path.join("admin")),
|
||||
)
|
||||
.nest_service(
|
||||
"/auth",
|
||||
tower_http::services::ServeDir::new(suite_path.join("auth")),
|
||||
)
|
||||
.nest_service(
|
||||
"/settings",
|
||||
tower_http::services::ServeDir::new(suite_path.join("settings")),
|
||||
)
|
||||
.nest_service(
|
||||
"/sources",
|
||||
tower_http::services::ServeDir::new(suite_path.join("sources")),
|
||||
)
|
||||
.nest_service(
|
||||
"/tools",
|
||||
tower_http::services::ServeDir::new(suite_path.join("tools")),
|
||||
)
|
||||
.nest_service(
|
||||
"/assets",
|
||||
tower_http::services::ServeDir::new(suite_path.join("assets")),
|
||||
)
|
||||
.nest_service(
|
||||
"/partials",
|
||||
tower_http::services::ServeDir::new(suite_path.join("partials")),
|
||||
)
|
||||
.nest_service(
|
||||
"/attendant",
|
||||
tower_http::services::ServeDir::new(suite_path.join("attendant")),
|
||||
)
|
||||
// Fallback for other static files (serve suite by default)
|
||||
.route("/suite", get(serve_suite));
|
||||
|
||||
router = add_static_routes(router, &suite_path);
|
||||
|
||||
router
|
||||
.fallback_service(
|
||||
tower_http::services::ServeDir::new(suite_path.clone()).fallback(
|
||||
tower_http::services::ServeDir::new(suite_path)
|
||||
.append_index_html_on_directories(true),
|
||||
),
|
||||
ServeDir::new(suite_path.clone())
|
||||
.fallback(ServeDir::new(suite_path).append_index_html_on_directories(true)),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
/* Chat module JavaScript - including projector component */
|
||||
|
||||
// Projector State
|
||||
let projectorState = {
|
||||
isOpen: false,
|
||||
contentType: null,
|
||||
|
|
@ -16,92 +13,79 @@ let projectorState = {
|
|||
isLooping: false,
|
||||
isMuted: false,
|
||||
lineNumbers: true,
|
||||
wordWrap: false
|
||||
wordWrap: false,
|
||||
};
|
||||
|
||||
// Get media element
|
||||
function getMediaElement() {
|
||||
return document.querySelector('.projector-video, .projector-audio');
|
||||
return document.querySelector(".projector-video, .projector-audio");
|
||||
}
|
||||
|
||||
// Open Projector
|
||||
function openProjector(data) {
|
||||
const overlay = document.getElementById('projector-overlay');
|
||||
const content = document.getElementById('projector-content');
|
||||
const loading = document.getElementById('projector-loading');
|
||||
const title = document.getElementById('projector-title');
|
||||
const icon = document.getElementById('projector-icon');
|
||||
|
||||
// Reset state
|
||||
const overlay = document.getElementById("projector-overlay");
|
||||
const content = document.getElementById("projector-content");
|
||||
const loading = document.getElementById("projector-loading");
|
||||
const title = document.getElementById("projector-title");
|
||||
const icon = document.getElementById("projector-icon");
|
||||
projectorState = {
|
||||
...projectorState,
|
||||
isOpen: true,
|
||||
contentType: data.content_type,
|
||||
source: data.source_url,
|
||||
options: data.options || {}
|
||||
options: data.options || {},
|
||||
};
|
||||
title.textContent = data.title || "Content Viewer";
|
||||
|
||||
// Set title
|
||||
title.textContent = data.title || 'Content Viewer';
|
||||
|
||||
// Set icon based on content type
|
||||
const icons = {
|
||||
'Video': '🎬',
|
||||
'Audio': '🎵',
|
||||
'Image': '🖼️',
|
||||
'Pdf': '📄',
|
||||
'Presentation': '📊',
|
||||
'Code': '💻',
|
||||
'Spreadsheet': '📈',
|
||||
'Markdown': '📝',
|
||||
'Html': '🌐',
|
||||
'Document': '📃'
|
||||
Video: "🎬",
|
||||
Audio: "🎵",
|
||||
Image: "🖼️",
|
||||
Pdf: "📄",
|
||||
Presentation: "📊",
|
||||
Code: "💻",
|
||||
Spreadsheet: "📈",
|
||||
Markdown: "📝",
|
||||
Html: "🌐",
|
||||
Document: "📃",
|
||||
};
|
||||
icon.textContent = icons[data.content_type] || '📁';
|
||||
|
||||
// Show loading
|
||||
loading.classList.remove('hidden');
|
||||
icon.textContent = icons[data.content_type] || "📁";
|
||||
loading.classList.remove("hidden");
|
||||
hideAllControls();
|
||||
|
||||
// Show overlay
|
||||
overlay.classList.remove('hidden');
|
||||
|
||||
// Load content based on type
|
||||
overlay.classList.remove("hidden");
|
||||
loadContent(data);
|
||||
}
|
||||
|
||||
// Load Content
|
||||
function loadContent(data) {
|
||||
const content = document.getElementById('projector-content');
|
||||
const loading = document.getElementById('projector-loading');
|
||||
const content = document.getElementById("projector-content");
|
||||
const loading = document.getElementById("projector-loading");
|
||||
|
||||
setTimeout(() => {
|
||||
loading.classList.add('hidden');
|
||||
loading.classList.add("hidden");
|
||||
|
||||
switch (data.content_type) {
|
||||
case 'Video':
|
||||
case "Video":
|
||||
loadVideo(content, data);
|
||||
break;
|
||||
case 'Audio':
|
||||
case "Audio":
|
||||
loadAudio(content, data);
|
||||
break;
|
||||
case 'Image':
|
||||
case "Image":
|
||||
loadImage(content, data);
|
||||
break;
|
||||
case 'Pdf':
|
||||
case "Pdf":
|
||||
loadPdf(content, data);
|
||||
break;
|
||||
case 'Presentation':
|
||||
case "Presentation":
|
||||
loadPresentation(content, data);
|
||||
break;
|
||||
case 'Code':
|
||||
case "Code":
|
||||
loadCode(content, data);
|
||||
break;
|
||||
case 'Markdown':
|
||||
case "Markdown":
|
||||
loadMarkdown(content, data);
|
||||
break;
|
||||
case 'Iframe':
|
||||
case 'Html':
|
||||
case "Iframe":
|
||||
case "Html":
|
||||
loadIframe(content, data);
|
||||
break;
|
||||
default:
|
||||
|
|
@ -112,81 +96,76 @@ function loadContent(data) {
|
|||
|
||||
// Load Video
|
||||
function loadVideo(container, data) {
|
||||
const loading = document.getElementById('projector-loading');
|
||||
const loading = document.getElementById("projector-loading");
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.className = 'projector-video';
|
||||
const video = document.createElement("video");
|
||||
video.className = "projector-video";
|
||||
video.src = data.source_url;
|
||||
video.controls = false;
|
||||
video.autoplay = data.options?.autoplay || false;
|
||||
video.loop = data.options?.loop_content || false;
|
||||
video.muted = data.options?.muted || false;
|
||||
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
loading.classList.add('hidden');
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
loading.classList.add("hidden");
|
||||
updateTimeDisplay();
|
||||
});
|
||||
|
||||
video.addEventListener('timeupdate', () => {
|
||||
video.addEventListener("timeupdate", () => {
|
||||
updateProgress();
|
||||
updateTimeDisplay();
|
||||
});
|
||||
|
||||
video.addEventListener('play', () => {
|
||||
video.addEventListener("play", () => {
|
||||
projectorState.isPlaying = true;
|
||||
document.getElementById('play-pause-btn').textContent = '⏸️';
|
||||
document.getElementById("play-pause-btn").textContent = "⏸️";
|
||||
});
|
||||
|
||||
video.addEventListener('pause', () => {
|
||||
video.addEventListener("pause", () => {
|
||||
projectorState.isPlaying = false;
|
||||
document.getElementById('play-pause-btn').textContent = '▶️';
|
||||
document.getElementById("play-pause-btn").textContent = "▶️";
|
||||
});
|
||||
|
||||
video.addEventListener('ended', () => {
|
||||
video.addEventListener("ended", () => {
|
||||
if (!projectorState.isLooping) {
|
||||
projectorState.isPlaying = false;
|
||||
document.getElementById('play-pause-btn').textContent = '▶️';
|
||||
document.getElementById("play-pause-btn").textContent = "▶️";
|
||||
}
|
||||
});
|
||||
|
||||
// Clear and add video
|
||||
clearContent(container);
|
||||
container.appendChild(video);
|
||||
|
||||
// Show media controls
|
||||
showControls('media');
|
||||
showControls("media");
|
||||
}
|
||||
|
||||
// Load Audio
|
||||
function loadAudio(container, data) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.textAlign = 'center';
|
||||
wrapper.style.padding = '40px';
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.style.textAlign = "center";
|
||||
wrapper.style.padding = "40px";
|
||||
|
||||
// Visualizer placeholder
|
||||
const visualizer = document.createElement('canvas');
|
||||
visualizer.className = 'audio-visualizer';
|
||||
visualizer.id = 'audio-visualizer';
|
||||
const visualizer = document.createElement("canvas");
|
||||
visualizer.className = "audio-visualizer";
|
||||
visualizer.id = "audio-visualizer";
|
||||
wrapper.appendChild(visualizer);
|
||||
|
||||
const audio = document.createElement('audio');
|
||||
audio.className = 'projector-audio';
|
||||
const audio = document.createElement("audio");
|
||||
audio.className = "projector-audio";
|
||||
audio.src = data.source_url;
|
||||
audio.autoplay = data.options?.autoplay || false;
|
||||
audio.loop = data.options?.loop_content || false;
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => updateTimeDisplay());
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
audio.addEventListener("loadedmetadata", () => updateTimeDisplay());
|
||||
audio.addEventListener("timeupdate", () => {
|
||||
updateProgress();
|
||||
updateTimeDisplay();
|
||||
});
|
||||
audio.addEventListener('play', () => {
|
||||
audio.addEventListener("play", () => {
|
||||
projectorState.isPlaying = true;
|
||||
document.getElementById('play-pause-btn').textContent = '⏸️';
|
||||
document.getElementById("play-pause-btn").textContent = "⏸️";
|
||||
});
|
||||
audio.addEventListener('pause', () => {
|
||||
audio.addEventListener("pause", () => {
|
||||
projectorState.isPlaying = false;
|
||||
document.getElementById('play-pause-btn').textContent = '▶️';
|
||||
document.getElementById("play-pause-btn").textContent = "▶️";
|
||||
});
|
||||
|
||||
wrapper.appendChild(audio);
|
||||
|
|
@ -194,63 +173,60 @@ function loadAudio(container, data) {
|
|||
clearContent(container);
|
||||
container.appendChild(wrapper);
|
||||
|
||||
showControls('media');
|
||||
showControls("media");
|
||||
}
|
||||
|
||||
// Load Image
|
||||
function loadImage(container, data) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'projector-image';
|
||||
const img = document.createElement("img");
|
||||
img.className = "projector-image";
|
||||
img.src = data.source_url;
|
||||
img.alt = data.title || 'Image';
|
||||
img.id = 'projector-img';
|
||||
img.alt = data.title || "Image";
|
||||
img.id = "projector-img";
|
||||
|
||||
img.addEventListener('load', () => {
|
||||
document.getElementById('projector-loading').classList.add('hidden');
|
||||
img.addEventListener("load", () => {
|
||||
document.getElementById("projector-loading").classList.add("hidden");
|
||||
});
|
||||
|
||||
img.addEventListener('error', () => {
|
||||
showError('Failed to load image');
|
||||
img.addEventListener("error", () => {
|
||||
showError("Failed to load image");
|
||||
});
|
||||
|
||||
clearContent(container);
|
||||
container.appendChild(img);
|
||||
document.getElementById("prev-image-btn").style.display =
|
||||
projectorState.totalImages > 1 ? "block" : "none";
|
||||
document.getElementById("next-image-btn").style.display =
|
||||
projectorState.totalImages > 1 ? "block" : "none";
|
||||
|
||||
// Hide nav if single image
|
||||
document.getElementById('prev-image-btn').style.display =
|
||||
projectorState.totalImages > 1 ? 'block' : 'none';
|
||||
document.getElementById('next-image-btn').style.display =
|
||||
projectorState.totalImages > 1 ? 'block' : 'none';
|
||||
|
||||
showControls('image');
|
||||
showControls("image");
|
||||
updateImageInfo();
|
||||
}
|
||||
|
||||
// Load PDF
|
||||
function loadPdf(container, data) {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'projector-pdf';
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.className = "projector-pdf";
|
||||
iframe.src = `/static/pdfjs/web/viewer.html?file=${encodeURIComponent(data.source_url)}`;
|
||||
|
||||
clearContent(container);
|
||||
container.appendChild(iframe);
|
||||
|
||||
showControls('slide');
|
||||
showControls("slide");
|
||||
}
|
||||
|
||||
// Load Presentation
|
||||
function loadPresentation(container, data) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'projector-presentation';
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "projector-presentation";
|
||||
|
||||
const slideContainer = document.createElement('div');
|
||||
slideContainer.className = 'slide-container';
|
||||
slideContainer.id = 'slide-container';
|
||||
const slideContainer = document.createElement("div");
|
||||
slideContainer.className = "slide-container";
|
||||
slideContainer.id = "slide-container";
|
||||
|
||||
// For now, show as images (each slide converted to image)
|
||||
const slideImg = document.createElement('img');
|
||||
slideImg.className = 'slide-content';
|
||||
slideImg.id = 'slide-content';
|
||||
const slideImg = document.createElement("img");
|
||||
slideImg.className = "slide-content";
|
||||
slideImg.id = "slide-content";
|
||||
slideImg.src = `${data.source_url}?slide=1`;
|
||||
|
||||
slideContainer.appendChild(slideImg);
|
||||
|
|
@ -259,39 +235,37 @@ function loadPresentation(container, data) {
|
|||
clearContent(container);
|
||||
container.appendChild(wrapper);
|
||||
|
||||
showControls('slide');
|
||||
showControls("slide");
|
||||
updateSlideInfo();
|
||||
}
|
||||
|
||||
// Load Code
|
||||
function loadCode(container, data) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'projector-code';
|
||||
wrapper.id = 'code-container';
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "projector-code";
|
||||
wrapper.id = "code-container";
|
||||
if (projectorState.lineNumbers) {
|
||||
wrapper.classList.add('line-numbers');
|
||||
wrapper.classList.add("line-numbers");
|
||||
}
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
const code = document.createElement('code');
|
||||
const pre = document.createElement("pre");
|
||||
const code = document.createElement("code");
|
||||
|
||||
// Fetch code content
|
||||
fetch(data.source_url)
|
||||
.then(res => res.text())
|
||||
.then(text => {
|
||||
// Split into lines for line numbers
|
||||
const lines = text.split('\n').map(line =>
|
||||
`<span class="line">${escapeHtml(line)}</span>`
|
||||
).join('\n');
|
||||
.then((res) => res.text())
|
||||
.then((text) => {
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((line) => `<span class="line">${escapeHtml(line)}</span>`)
|
||||
.join("\n");
|
||||
code.innerHTML = lines;
|
||||
|
||||
// Apply syntax highlighting if Prism is available
|
||||
if (window.Prism) {
|
||||
Prism.highlightElement(code);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
code.textContent = 'Failed to load code';
|
||||
code.textContent = "Failed to load code";
|
||||
});
|
||||
|
||||
pre.appendChild(code);
|
||||
|
|
@ -299,27 +273,24 @@ function loadCode(container, data) {
|
|||
|
||||
clearContent(container);
|
||||
container.appendChild(wrapper);
|
||||
const filename = data.source_url.split("/").pop();
|
||||
document.getElementById("code-info").textContent = filename;
|
||||
|
||||
// Update code info
|
||||
const filename = data.source_url.split('/').pop();
|
||||
document.getElementById('code-info').textContent = filename;
|
||||
|
||||
showControls('code');
|
||||
showControls("code");
|
||||
}
|
||||
|
||||
// Load Markdown
|
||||
function loadMarkdown(container, data) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'projector-markdown';
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "projector-markdown";
|
||||
|
||||
fetch(data.source_url)
|
||||
.then(res => res.text())
|
||||
.then(text => {
|
||||
// Simple markdown parsing (use marked.js in production)
|
||||
.then((res) => res.text())
|
||||
.then((text) => {
|
||||
wrapper.innerHTML = parseMarkdown(text);
|
||||
})
|
||||
.catch(() => {
|
||||
wrapper.innerHTML = '<p>Failed to load markdown</p>';
|
||||
wrapper.innerHTML = "<p>Failed to load markdown</p>";
|
||||
});
|
||||
|
||||
clearContent(container);
|
||||
|
|
@ -330,10 +301,11 @@ function loadMarkdown(container, data) {
|
|||
|
||||
// Load Iframe
|
||||
function loadIframe(container, data) {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'projector-iframe';
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.className = "projector-iframe";
|
||||
iframe.src = data.source_url;
|
||||
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
|
||||
iframe.allow =
|
||||
"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture";
|
||||
iframe.allowFullscreen = true;
|
||||
|
||||
clearContent(container);
|
||||
|
|
@ -344,10 +316,10 @@ function loadIframe(container, data) {
|
|||
|
||||
// Load Generic
|
||||
function loadGeneric(container, data) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.textAlign = 'center';
|
||||
wrapper.style.padding = '40px';
|
||||
wrapper.style.color = '#888';
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.style.textAlign = "center";
|
||||
wrapper.style.padding = "40px";
|
||||
wrapper.style.color = "#888";
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div style="font-size: 64px; margin-bottom: 20px;">📁</div>
|
||||
|
|
@ -365,7 +337,7 @@ function loadGeneric(container, data) {
|
|||
|
||||
// Show Error
|
||||
function showError(message) {
|
||||
const content = document.getElementById('projector-content');
|
||||
const content = document.getElementById("projector-content");
|
||||
content.innerHTML = `
|
||||
<div class="projector-error">
|
||||
<span class="projector-error-icon">❌</span>
|
||||
|
|
@ -376,8 +348,8 @@ function showError(message) {
|
|||
|
||||
// Clear Content
|
||||
function clearContent(container) {
|
||||
const loading = document.getElementById('projector-loading');
|
||||
container.innerHTML = '';
|
||||
const loading = document.getElementById("projector-loading");
|
||||
container.innerHTML = "";
|
||||
container.appendChild(loading);
|
||||
}
|
||||
|
||||
|
|
@ -386,39 +358,37 @@ function showControls(type) {
|
|||
hideAllControls();
|
||||
const controls = document.getElementById(`${type}-controls`);
|
||||
if (controls) {
|
||||
controls.classList.remove('hidden');
|
||||
controls.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function hideAllControls() {
|
||||
document.getElementById('media-controls')?.classList.add('hidden');
|
||||
document.getElementById('slide-controls')?.classList.add('hidden');
|
||||
document.getElementById('image-controls')?.classList.add('hidden');
|
||||
document.getElementById('code-controls')?.classList.add('hidden');
|
||||
document.getElementById("media-controls")?.classList.add("hidden");
|
||||
document.getElementById("slide-controls")?.classList.add("hidden");
|
||||
document.getElementById("image-controls")?.classList.add("hidden");
|
||||
document.getElementById("code-controls")?.classList.add("hidden");
|
||||
}
|
||||
|
||||
// Close Projector
|
||||
function closeProjector() {
|
||||
const overlay = document.getElementById('projector-overlay');
|
||||
overlay.classList.add('hidden');
|
||||
const overlay = document.getElementById("projector-overlay");
|
||||
overlay.classList.add("hidden");
|
||||
projectorState.isOpen = false;
|
||||
|
||||
// Stop any playing media
|
||||
const media = getMediaElement();
|
||||
if (media) {
|
||||
media.pause();
|
||||
media.src = '';
|
||||
media.src = "";
|
||||
}
|
||||
|
||||
// Clear content
|
||||
const content = document.getElementById('projector-content');
|
||||
const loading = document.getElementById('projector-loading');
|
||||
content.innerHTML = '';
|
||||
const content = document.getElementById("projector-content");
|
||||
const loading = document.getElementById("projector-loading");
|
||||
content.innerHTML = "";
|
||||
content.appendChild(loading);
|
||||
}
|
||||
|
||||
function closeProjectorOnOverlay(event) {
|
||||
if (event.target.id === 'projector-overlay') {
|
||||
if (event.target.id === "projector-overlay") {
|
||||
closeProjector();
|
||||
}
|
||||
}
|
||||
|
|
@ -461,7 +431,7 @@ function setVolume(value) {
|
|||
if (media) {
|
||||
media.volume = value / 100;
|
||||
projectorState.isMuted = value === 0;
|
||||
document.getElementById('mute-btn').textContent = value === 0 ? '🔇' : '🔊';
|
||||
document.getElementById("mute-btn").textContent = value === 0 ? "🔇" : "🔊";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -470,7 +440,7 @@ function toggleMute() {
|
|||
if (media) {
|
||||
media.muted = !media.muted;
|
||||
projectorState.isMuted = media.muted;
|
||||
document.getElementById('mute-btn').textContent = media.muted ? '🔇' : '🔊';
|
||||
document.getElementById("mute-btn").textContent = media.muted ? "🔇" : "🔊";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -479,7 +449,7 @@ function toggleLoop() {
|
|||
if (media) {
|
||||
media.loop = !media.loop;
|
||||
projectorState.isLooping = media.loop;
|
||||
document.getElementById('loop-btn').classList.toggle('active', media.loop);
|
||||
document.getElementById("loop-btn").classList.toggle("active", media.loop);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -494,7 +464,7 @@ function updateProgress() {
|
|||
const media = getMediaElement();
|
||||
if (media && media.duration) {
|
||||
const progress = (media.currentTime / media.duration) * 100;
|
||||
document.getElementById('progress-bar').value = progress;
|
||||
document.getElementById("progress-bar").value = progress;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -503,15 +473,16 @@ function updateTimeDisplay() {
|
|||
if (media) {
|
||||
const current = formatTime(media.currentTime);
|
||||
const duration = formatTime(media.duration || 0);
|
||||
document.getElementById('time-display').textContent = `${current} / ${duration}`;
|
||||
document.getElementById("time-display").textContent =
|
||||
`${current} / ${duration}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (isNaN(seconds)) return '0:00';
|
||||
if (isNaN(seconds)) return "0:00";
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Slide/Page Controls
|
||||
|
|
@ -538,7 +509,7 @@ function goToSlide(num) {
|
|||
}
|
||||
|
||||
function updateSlide() {
|
||||
const slideContent = document.getElementById('slide-content');
|
||||
const slideContent = document.getElementById("slide-content");
|
||||
if (slideContent) {
|
||||
slideContent.src = `${projectorState.source}?slide=${projectorState.currentSlide}`;
|
||||
}
|
||||
|
|
@ -546,9 +517,9 @@ function updateSlide() {
|
|||
}
|
||||
|
||||
function updateSlideInfo() {
|
||||
document.getElementById('slide-info').textContent =
|
||||
document.getElementById("slide-info").textContent =
|
||||
`Slide ${projectorState.currentSlide} of ${projectorState.totalSlides}`;
|
||||
document.getElementById('slide-input').value = projectorState.currentSlide;
|
||||
document.getElementById("slide-input").value = projectorState.currentSlide;
|
||||
}
|
||||
|
||||
// Image Controls
|
||||
|
|
@ -567,18 +538,17 @@ function nextImage() {
|
|||
}
|
||||
|
||||
function updateImage() {
|
||||
// Implementation for image galleries
|
||||
updateImageInfo();
|
||||
}
|
||||
|
||||
function updateImageInfo() {
|
||||
document.getElementById('image-info').textContent =
|
||||
document.getElementById("image-info").textContent =
|
||||
`${projectorState.currentImage + 1} of ${projectorState.totalImages}`;
|
||||
}
|
||||
|
||||
function rotateImage() {
|
||||
projectorState.rotation = (projectorState.rotation + 90) % 360;
|
||||
const img = document.getElementById('projector-img');
|
||||
const img = document.getElementById("projector-img");
|
||||
if (img) {
|
||||
img.style.transform = `rotate(${projectorState.rotation}deg) scale(${projectorState.zoom / 100})`;
|
||||
}
|
||||
|
|
@ -587,11 +557,11 @@ function rotateImage() {
|
|||
function fitToScreen() {
|
||||
projectorState.zoom = 100;
|
||||
projectorState.rotation = 0;
|
||||
const img = document.getElementById('projector-img');
|
||||
const img = document.getElementById("projector-img");
|
||||
if (img) {
|
||||
img.style.transform = 'none';
|
||||
img.style.transform = "none";
|
||||
}
|
||||
document.getElementById('zoom-level').textContent = '100%';
|
||||
document.getElementById("zoom-level").textContent = "100%";
|
||||
}
|
||||
|
||||
// Zoom Controls
|
||||
|
|
@ -606,8 +576,8 @@ function zoomOut() {
|
|||
}
|
||||
|
||||
function applyZoom() {
|
||||
const img = document.getElementById('projector-img');
|
||||
const slideContainer = document.getElementById('slide-container');
|
||||
const img = document.getElementById("projector-img");
|
||||
const slideContainer = document.getElementById("slide-container");
|
||||
|
||||
if (img) {
|
||||
img.style.transform = `rotate(${projectorState.rotation}deg) scale(${projectorState.zoom / 100})`;
|
||||
|
|
@ -616,171 +586,181 @@ function applyZoom() {
|
|||
slideContainer.style.transform = `scale(${projectorState.zoom / 100})`;
|
||||
}
|
||||
|
||||
document.getElementById('zoom-level').textContent = `${projectorState.zoom}%`;
|
||||
document.getElementById("zoom-level").textContent = `${projectorState.zoom}%`;
|
||||
}
|
||||
|
||||
// Code Controls
|
||||
function toggleLineNumbers() {
|
||||
projectorState.lineNumbers = !projectorState.lineNumbers;
|
||||
const container = document.getElementById('code-container');
|
||||
const container = document.getElementById("code-container");
|
||||
if (container) {
|
||||
container.classList.toggle('line-numbers', projectorState.lineNumbers);
|
||||
container.classList.toggle("line-numbers", projectorState.lineNumbers);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWordWrap() {
|
||||
projectorState.wordWrap = !projectorState.wordWrap;
|
||||
const container = document.getElementById('code-container');
|
||||
const container = document.getElementById("code-container");
|
||||
if (container) {
|
||||
container.style.whiteSpace = projectorState.wordWrap ? 'pre-wrap' : 'pre';
|
||||
container.style.whiteSpace = projectorState.wordWrap ? "pre-wrap" : "pre";
|
||||
}
|
||||
}
|
||||
|
||||
function setCodeTheme(theme) {
|
||||
const container = document.getElementById('code-container');
|
||||
const container = document.getElementById("code-container");
|
||||
if (container) {
|
||||
container.className = `projector-code ${projectorState.lineNumbers ? 'line-numbers' : ''} theme-${theme}`;
|
||||
container.className = `projector-code ${projectorState.lineNumbers ? "line-numbers" : ""} theme-${theme}`;
|
||||
}
|
||||
}
|
||||
|
||||
function copyCode() {
|
||||
const code = document.querySelector('.projector-code code');
|
||||
const code = document.querySelector(".projector-code code");
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code.textContent).then(() => {
|
||||
// Show feedback
|
||||
const btn = document.querySelector('.code-controls .control-btn:last-child');
|
||||
const btn = document.querySelector(
|
||||
".code-controls .control-btn:last-child",
|
||||
);
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = '✅';
|
||||
setTimeout(() => btn.textContent = originalText, 2000);
|
||||
btn.textContent = "✅";
|
||||
setTimeout(() => (btn.textContent = originalText), 2000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fullscreen
|
||||
function toggleFullscreen() {
|
||||
const container = document.querySelector('.projector-container');
|
||||
const icon = document.getElementById('fullscreen-icon');
|
||||
const container = document.querySelector(".projector-container");
|
||||
const icon = document.getElementById("fullscreen-icon");
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
container.requestFullscreen().then(() => {
|
||||
container.classList.add('fullscreen');
|
||||
icon.textContent = '⛶';
|
||||
}).catch(() => {});
|
||||
container
|
||||
.requestFullscreen()
|
||||
.then(() => {
|
||||
container.classList.add("fullscreen");
|
||||
icon.textContent = "⛶";
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen().then(() => {
|
||||
container.classList.remove('fullscreen');
|
||||
icon.textContent = '⛶';
|
||||
}).catch(() => {});
|
||||
document
|
||||
.exitFullscreen()
|
||||
.then(() => {
|
||||
container.classList.remove("fullscreen");
|
||||
icon.textContent = "⛶";
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Download
|
||||
function downloadContent() {
|
||||
const link = document.createElement('a');
|
||||
const link = document.createElement("a");
|
||||
link.href = projectorState.source;
|
||||
link.download = '';
|
||||
link.download = "";
|
||||
link.click();
|
||||
}
|
||||
|
||||
// Share
|
||||
function shareContent() {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: document.getElementById('projector-title').textContent,
|
||||
url: projectorState.source
|
||||
}).catch(() => {});
|
||||
navigator
|
||||
.share({
|
||||
title: document.getElementById("projector-title").textContent,
|
||||
url: projectorState.source,
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
navigator.clipboard.writeText(window.location.origin + projectorState.source).then(() => {
|
||||
alert('Link copied to clipboard!');
|
||||
navigator.clipboard
|
||||
.writeText(window.location.origin + projectorState.source)
|
||||
.then(() => {
|
||||
alert("Link copied to clipboard!");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts for projector
|
||||
document.addEventListener('keydown', (e) => {
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (!projectorState.isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
case "Escape":
|
||||
closeProjector();
|
||||
break;
|
||||
case ' ':
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
togglePlayPause();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (projectorState.contentType === 'Video' || projectorState.contentType === 'Audio') {
|
||||
case "ArrowLeft":
|
||||
if (
|
||||
projectorState.contentType === "Video" ||
|
||||
projectorState.contentType === "Audio"
|
||||
) {
|
||||
mediaSeekBack();
|
||||
} else {
|
||||
prevSlide();
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (projectorState.contentType === 'Video' || projectorState.contentType === 'Audio') {
|
||||
case "ArrowRight":
|
||||
if (
|
||||
projectorState.contentType === "Video" ||
|
||||
projectorState.contentType === "Audio"
|
||||
) {
|
||||
mediaSeekForward();
|
||||
} else {
|
||||
nextSlide();
|
||||
}
|
||||
break;
|
||||
case 'f':
|
||||
case "f":
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case 'm':
|
||||
case "m":
|
||||
toggleMute();
|
||||
break;
|
||||
case '+':
|
||||
case '=':
|
||||
case "+":
|
||||
case "=":
|
||||
zoomIn();
|
||||
break;
|
||||
case '-':
|
||||
case "-":
|
||||
zoomOut();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Helper Functions
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function parseMarkdown(text) {
|
||||
// Simple markdown parsing - use marked.js for full support
|
||||
return text
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*)\*/gim, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/gim, '<code>$1</code>')
|
||||
.replace(/\n/gim, '<br>');
|
||||
.replace(/^### (.*$)/gim, "<h3>$1</h3>")
|
||||
.replace(/^## (.*$)/gim, "<h2>$1</h2>")
|
||||
.replace(/^# (.*$)/gim, "<h1>$1</h1>")
|
||||
.replace(/\*\*(.*)\*\*/gim, "<strong>$1</strong>")
|
||||
.replace(/\*(.*)\*/gim, "<em>$1</em>")
|
||||
.replace(/`([^`]+)`/gim, "<code>$1</code>")
|
||||
.replace(/\n/gim, "<br>");
|
||||
}
|
||||
|
||||
// Listen for play messages from WebSocket
|
||||
if (window.htmx) {
|
||||
htmx.on('htmx:wsMessage', function(event) {
|
||||
htmx.on("htmx:wsMessage", function (event) {
|
||||
try {
|
||||
const data = JSON.parse(event.detail.message);
|
||||
if (data.type === 'play') {
|
||||
if (data.type === "play") {
|
||||
openProjector(data.data);
|
||||
} else if (data.type === 'player_command') {
|
||||
} else if (data.type === "player_command") {
|
||||
switch (data.command) {
|
||||
case 'stop':
|
||||
case "stop":
|
||||
closeProjector();
|
||||
break;
|
||||
case 'pause':
|
||||
case "pause":
|
||||
const media = getMediaElement();
|
||||
if (media) media.pause();
|
||||
break;
|
||||
case 'resume':
|
||||
case "resume":
|
||||
const mediaR = getMediaElement();
|
||||
if (mediaR) mediaR.play();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a projector message
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -1,143 +1,131 @@
|
|||
/**
|
||||
* Mail Module JavaScript
|
||||
* Email client functionality including compose, selection, and modals
|
||||
*/
|
||||
|
||||
// Compose Modal Functions
|
||||
function openCompose(replyTo = null, forward = null) {
|
||||
const modal = document.getElementById('composeModal');
|
||||
const modal = document.getElementById("composeModal");
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.remove('minimized');
|
||||
modal.classList.remove("hidden");
|
||||
modal.classList.remove("minimized");
|
||||
if (replyTo) {
|
||||
document.getElementById('composeTo').value = replyTo;
|
||||
document.getElementById("composeTo").value = replyTo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeCompose() {
|
||||
const modal = document.getElementById('composeModal');
|
||||
const modal = document.getElementById("composeModal");
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
// Clear form
|
||||
document.getElementById('composeTo').value = '';
|
||||
document.getElementById('composeCc').value = '';
|
||||
document.getElementById('composeBcc').value = '';
|
||||
document.getElementById('composeSubject').value = '';
|
||||
document.getElementById('composeBody').value = '';
|
||||
modal.classList.add("hidden");
|
||||
document.getElementById("composeTo").value = "";
|
||||
document.getElementById("composeCc").value = "";
|
||||
document.getElementById("composeBcc").value = "";
|
||||
document.getElementById("composeSubject").value = "";
|
||||
document.getElementById("composeBody").value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function minimizeCompose() {
|
||||
const modal = document.getElementById('composeModal');
|
||||
const modal = document.getElementById("composeModal");
|
||||
if (modal) {
|
||||
modal.classList.toggle('minimized');
|
||||
modal.classList.toggle("minimized");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCcBcc() {
|
||||
const ccBcc = document.getElementById('ccBccFields');
|
||||
const ccBcc = document.getElementById("ccBccFields");
|
||||
if (ccBcc) {
|
||||
ccBcc.classList.toggle('hidden');
|
||||
ccBcc.classList.toggle("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule Functions
|
||||
function toggleScheduleMenu() {
|
||||
const menu = document.getElementById('scheduleMenu');
|
||||
const menu = document.getElementById("scheduleMenu");
|
||||
if (menu) {
|
||||
menu.classList.toggle('hidden');
|
||||
menu.classList.toggle("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSend(when) {
|
||||
console.log('Scheduling send for:', when);
|
||||
console.log("Scheduling send for:", when);
|
||||
toggleScheduleMenu();
|
||||
}
|
||||
|
||||
// Selection Functions
|
||||
function toggleSelectAll() {
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const checkboxes = document.querySelectorAll('.email-checkbox');
|
||||
checkboxes.forEach(cb => cb.checked = selectAll.checked);
|
||||
const selectAll = document.getElementById("selectAll");
|
||||
const checkboxes = document.querySelectorAll(".email-checkbox");
|
||||
checkboxes.forEach((cb) => (cb.checked = selectAll.checked));
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function updateBulkActions() {
|
||||
const checked = document.querySelectorAll('.email-checkbox:checked');
|
||||
const bulkActions = document.getElementById('bulkActions');
|
||||
const checked = document.querySelectorAll(".email-checkbox:checked");
|
||||
const bulkActions = document.getElementById("bulkActions");
|
||||
if (bulkActions) {
|
||||
bulkActions.style.display = checked.length > 0 ? 'flex' : 'none';
|
||||
bulkActions.style.display = checked.length > 0 ? "flex" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Modal Functions
|
||||
function openTemplatesModal() {
|
||||
const modal = document.getElementById('templatesModal');
|
||||
if (modal) modal.classList.remove('hidden');
|
||||
const modal = document.getElementById("templatesModal");
|
||||
if (modal) modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function closeTemplatesModal() {
|
||||
const modal = document.getElementById('templatesModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
const modal = document.getElementById("templatesModal");
|
||||
if (modal) modal.classList.add("hidden");
|
||||
}
|
||||
|
||||
function openSignaturesModal() {
|
||||
const modal = document.getElementById('signaturesModal');
|
||||
if (modal) modal.classList.remove('hidden');
|
||||
const modal = document.getElementById("signaturesModal");
|
||||
if (modal) modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function closeSignaturesModal() {
|
||||
const modal = document.getElementById('signaturesModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
const modal = document.getElementById("signaturesModal");
|
||||
if (modal) modal.classList.add("hidden");
|
||||
}
|
||||
|
||||
function openRulesModal() {
|
||||
const modal = document.getElementById('rulesModal');
|
||||
if (modal) modal.classList.remove('hidden');
|
||||
const modal = document.getElementById("rulesModal");
|
||||
if (modal) modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function closeRulesModal() {
|
||||
const modal = document.getElementById('rulesModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
const modal = document.getElementById("rulesModal");
|
||||
if (modal) modal.classList.add("hidden");
|
||||
}
|
||||
|
||||
function useTemplate(name) {
|
||||
console.log('Using template:', name);
|
||||
console.log("Using template:", name);
|
||||
closeTemplatesModal();
|
||||
}
|
||||
|
||||
function useSignature(name) {
|
||||
console.log('Using signature:', name);
|
||||
console.log("Using signature:", name);
|
||||
closeSignaturesModal();
|
||||
}
|
||||
|
||||
// Bulk Actions
|
||||
function archiveSelected() {
|
||||
const checked = document.querySelectorAll('.email-checkbox:checked');
|
||||
console.log('Archiving', checked.length, 'emails');
|
||||
const checked = document.querySelectorAll(".email-checkbox:checked");
|
||||
console.log("Archiving", checked.length, "emails");
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
const checked = document.querySelectorAll('.email-checkbox:checked');
|
||||
const checked = document.querySelectorAll(".email-checkbox:checked");
|
||||
if (confirm(`Delete ${checked.length} email(s)?`)) {
|
||||
console.log('Deleting', checked.length, 'emails');
|
||||
console.log("Deleting", checked.length, "emails");
|
||||
}
|
||||
}
|
||||
|
||||
function markSelectedRead() {
|
||||
const checked = document.querySelectorAll('.email-checkbox:checked');
|
||||
console.log('Marking', checked.length, 'emails as read');
|
||||
const checked = document.querySelectorAll(".email-checkbox:checked");
|
||||
console.log("Marking", checked.length, "emails as read");
|
||||
}
|
||||
|
||||
// File Attachment
|
||||
function handleAttachment(input) {
|
||||
const files = input.files;
|
||||
const attachmentList = document.getElementById('attachmentList');
|
||||
const attachmentList = document.getElementById("attachmentList");
|
||||
if (attachmentList && files.length > 0) {
|
||||
for (const file of files) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'attachment-item';
|
||||
const item = document.createElement("div");
|
||||
item.className = "attachment-item";
|
||||
item.innerHTML = `
|
||||
<span>${file.name}</span>
|
||||
<button type="button" onclick="this.parentElement.remove()">×</button>
|
||||
|
|
@ -147,27 +135,22 @@ function handleAttachment(input) {
|
|||
}
|
||||
}
|
||||
|
||||
// Keyboard Shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
// Escape closes modals
|
||||
if (e.key === 'Escape') {
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape") {
|
||||
closeCompose();
|
||||
closeTemplatesModal();
|
||||
closeSignaturesModal();
|
||||
closeRulesModal();
|
||||
}
|
||||
|
||||
// Ctrl+N for new email
|
||||
if (e.ctrlKey && e.key === 'n') {
|
||||
if (e.ctrlKey && e.key === "n") {
|
||||
e.preventDefault();
|
||||
openCompose();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add change listeners to checkboxes
|
||||
document.querySelectorAll('.email-checkbox').forEach(cb => {
|
||||
cb.addEventListener('change', updateBulkActions);
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.querySelectorAll(".email-checkbox").forEach((cb) => {
|
||||
cb.addEventListener("change", updateBulkActions);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -373,8 +373,121 @@ function discardPlan() {
|
|||
}
|
||||
|
||||
function editPlan() {
|
||||
// TODO: Implement plan editor
|
||||
showToast("Plan editor coming soon!", "info");
|
||||
if (!AutoTaskState.compiledPlan) {
|
||||
showToast("No plan to edit", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "modal-overlay";
|
||||
modal.id = "plan-editor-modal";
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content large">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Plan</h3>
|
||||
<button class="close-btn" onclick="closePlanEditor()">×</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>
|
||||
`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue