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 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..."),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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()">×</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