diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 8bd4094e8..88d917ced 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -9,6 +9,9 @@ use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; +pub mod zitadel; +pub use zitadel::{UserWorkspace, ZitadelAuth, ZitadelConfig, ZitadelUser}; + pub struct AuthService {} impl AuthService { diff --git a/src/automation/mod.rs b/src/automation/mod.rs index c659919b7..05c3c835c 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -4,11 +4,15 @@ use crate::shared::state::AppState; use chrono::Utc; use cron::Schedule; use diesel::prelude::*; -use log::{error}; +use log::error; use std::str::FromStr; use std::sync::Arc; use tokio::time::{interval, Duration}; mod compact_prompt; +pub mod vectordb_indexer; + +pub use vectordb_indexer::{IndexingStats, IndexingStatus, VectorDBIndexer}; + pub struct AutomationService { state: Arc, } @@ -56,17 +60,24 @@ impl AutomationService { if let Err(e) = self.execute_automation(&automation).await { error!("Error executing automation {}: {}", automation.id, e); } - if let Err(e) = diesel::update(system_automations.filter(id.eq(automation.id))) - .set(lt_column.eq(Some(now))) - .execute(&mut conn) + if let Err(e) = + diesel::update(system_automations.filter(id.eq(automation.id))) + .set(lt_column.eq(Some(now))) + .execute(&mut conn) { - error!("Error updating last_triggered for automation {}: {}", automation.id, e); + error!( + "Error updating last_triggered for automation {}: {}", + automation.id, e + ); } } } } Err(e) => { - error!("Error parsing schedule for automation {} ({}): {}", automation.id, schedule_str, e); + error!( + "Error parsing schedule for automation {} ({}): {}", + automation.id, schedule_str, e + ); } } } diff --git a/src/basic/keywords/mod.rs b/src/basic/keywords/mod.rs index 3d4a04264..7ca86007a 100644 --- a/src/basic/keywords/mod.rs +++ b/src/basic/keywords/mod.rs @@ -1,7 +1,12 @@ +pub mod add_kb; +pub mod add_suggestion; pub mod add_tool; pub mod add_website; pub mod bot_memory; +pub mod clear_kb; pub mod clear_tools; +#[cfg(feature = "email")] +pub mod create_draft_keyword; pub mod create_site; pub mod find; pub mod first; @@ -15,11 +20,8 @@ pub mod llm_keyword; pub mod on; pub mod print; pub mod set; -pub mod set_schedule; -pub mod set_kb; -pub mod wait; -pub mod add_suggestion; -pub mod set_user; pub mod set_context; -#[cfg(feature = "email")] -pub mod create_draft_keyword; +pub mod set_kb; +pub mod set_schedule; +pub mod set_user; +pub mod wait; diff --git a/src/basic/mod.rs b/src/basic/mod.rs index 1b63434bb..203a6c5e9 100644 --- a/src/basic/mod.rs +++ b/src/basic/mod.rs @@ -7,10 +7,15 @@ use rhai::{Dynamic, Engine, EvalAltResult}; use std::sync::Arc; pub mod compiler; pub mod keywords; +use self::keywords::add_kb::register_add_kb_keyword; +use self::keywords::add_suggestion::add_suggestion_keyword; use self::keywords::add_tool::add_tool_keyword; use self::keywords::add_website::add_website_keyword; use self::keywords::bot_memory::{get_bot_memory_keyword, set_bot_memory_keyword}; +use self::keywords::clear_kb::register_clear_kb_keyword; use self::keywords::clear_tools::clear_tools_keyword; +#[cfg(feature = "email")] +use self::keywords::create_draft_keyword; use self::keywords::create_site::create_site_keyword; use self::keywords::find::find_keyword; use self::keywords::first::first_keyword; @@ -18,21 +23,18 @@ use self::keywords::for_next::for_keyword; use self::keywords::format::format_keyword; use self::keywords::get::get_keyword; use self::keywords::hear_talk::{hear_keyword, talk_keyword}; -use self::keywords::set_context::set_context_keyword; use self::keywords::last::last_keyword; use self::keywords::list_tools::list_tools_keyword; use self::keywords::llm_keyword::llm_keyword; use self::keywords::on::on_keyword; use self::keywords::print::print_keyword; use self::keywords::set::set_keyword; +use self::keywords::set_context::set_context_keyword; use self::keywords::set_kb::{add_kb_keyword, set_kb_keyword}; use self::keywords::wait::wait_keyword; -use self::keywords::add_suggestion::add_suggestion_keyword; -#[cfg(feature = "email")] -use self::keywords::create_draft_keyword; pub struct ScriptService { pub engine: Engine, - } +} impl ScriptService { pub fn new(state: Arc, user: UserSession) -> Self { let mut engine = Engine::new(); @@ -45,6 +47,8 @@ impl ScriptService { create_site_keyword(&state, user.clone(), &mut engine); find_keyword(&state, user.clone(), &mut engine); for_keyword(&state, user.clone(), &mut engine); + let _ = register_add_kb_keyword(&mut engine, state.clone(), Arc::new(user.clone())); + let _ = register_clear_kb_keyword(&mut engine, state.clone(), Arc::new(user.clone())); first_keyword(&mut engine); last_keyword(&mut engine); format_keyword(&mut engine); @@ -66,9 +70,7 @@ impl ScriptService { list_tools_keyword(state.clone(), user.clone(), &mut engine); add_website_keyword(state.clone(), user.clone(), &mut engine); add_suggestion_keyword(state.clone(), user.clone(), &mut engine); - ScriptService { - engine, - } + ScriptService { engine } } fn preprocess_basic_script(&self, script: &str) -> String { let mut result = String::new(); @@ -76,7 +78,7 @@ impl ScriptService { let mut current_indent = 0; for line in script.lines() { let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with("//"){ + if trimmed.is_empty() || trimmed.starts_with("//") { continue; } if trimmed.starts_with("FOR EACH") { diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index 1366c8ff3..e541bf260 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -1,14 +1,15 @@ use crate::config::AppConfig; +use crate::package_manager::setup::{DirectorySetup, EmailSetup}; use crate::package_manager::{InstallMode, PackageManager}; use crate::shared::utils::establish_pg_connection; use anyhow::Result; use aws_config::BehaviorVersion; use aws_sdk_s3::Client; use dotenvy::dotenv; -use log::{error, trace}; +use log::{error, info, trace}; use rand::distr::Alphanumeric; use std::io::{self, Write}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; pub struct ComponentInfo { pub name: &'static str, @@ -79,7 +80,7 @@ impl BootstrapManager { let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { format!("postgres://gbuser:{}@localhost:5432/botserver", db_password) }); - + let drive_password = self.generate_secure_password(16); let drive_user = "gbdriveuser".to_string(); let drive_env = format!( @@ -90,9 +91,8 @@ impl BootstrapManager { let _ = std::fs::write(&env_path, contents_env); dotenv().ok(); - let pm = PackageManager::new(self.install_mode.clone(), self.tenant.clone()).unwrap(); - let required_components = vec!["tables", "drive", "cache", "llm"]; + let required_components = vec!["tables", "drive", "cache", "llm", "directory", "email"]; for component in required_components { if !pm.is_installed(component) { let termination_cmd = pm @@ -133,11 +133,78 @@ impl BootstrapManager { let mut conn = establish_pg_connection().unwrap(); self.apply_migrations(&mut conn)?; } + + // Auto-configure Directory after installation + if component == "directory" { + info!("🔧 Auto-configuring Directory (Zitadel)..."); + if let Err(e) = self.setup_directory().await { + error!("Failed to setup Directory: {}", e); + } + } + + // Auto-configure Email after installation and Directory setup + if component == "email" { + info!("🔧 Auto-configuring Email (Stalwart)..."); + if let Err(e) = self.setup_email().await { + error!("Failed to setup Email: {}", e); + } + } } } Ok(()) } + /// Setup Directory (Zitadel) with default organization and user + async fn setup_directory(&self) -> Result<()> { + let config_path = PathBuf::from("./config/directory_config.json"); + let work_root = PathBuf::from("./work"); + + // Ensure config directory exists + tokio::fs::create_dir_all("./config").await?; + + let mut setup = DirectorySetup::new("http://localhost:8080".to_string(), config_path); + + let config = setup.initialize().await?; + + info!("✅ Directory initialized successfully!"); + info!(" Organization: {}", config.default_org.name); + info!( + " Default User: {} / {}", + config.default_user.email, config.default_user.password + ); + info!(" Client ID: {}", config.client_id); + info!(" Login URL: {}", config.base_url); + + Ok(()) + } + + /// Setup Email (Stalwart) with Directory integration + async fn setup_email(&self) -> Result<()> { + let config_path = PathBuf::from("./config/email_config.json"); + let directory_config_path = PathBuf::from("./config/directory_config.json"); + + let mut setup = EmailSetup::new("http://localhost:8080".to_string(), config_path); + + // Try to integrate with Directory if it exists + let directory_config = if directory_config_path.exists() { + Some(directory_config_path) + } else { + None + }; + + let config = setup.initialize(directory_config).await?; + + info!("✅ Email server initialized successfully!"); + info!(" SMTP: {}:{}", config.smtp_host, config.smtp_port); + info!(" IMAP: {}:{}", config.imap_host, config.imap_port); + info!(" Admin: {} / {}", config.admin_user, config.admin_pass); + if config.directory_integration { + info!(" 🔗 Integrated with Directory for authentication"); + } + + Ok(()) + } + async fn get_drive_client(config: &AppConfig) -> Client { let endpoint = if !config.drive.server.ends_with('/') { format!("{}/", config.drive.server) @@ -273,17 +340,17 @@ impl BootstrapManager { }) } pub fn apply_migrations(&self, conn: &mut diesel::PgConnection) -> Result<()> { - use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use diesel_migrations::HarnessWithOutput; - + use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; + const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); - + let mut harness = HarnessWithOutput::write_to_stdout(conn); if let Err(e) = harness.run_pending_migrations(MIGRATIONS) { error!("Failed to apply migrations: {}", e); return Err(anyhow::anyhow!("Migration error: {}", e)); } - + Ok(()) } } diff --git a/src/config/mod.rs b/src/config/mod.rs index abd144cc3..5088a4bf9 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -7,6 +7,7 @@ use uuid::Uuid; pub struct AppConfig { pub drive: DriveConfig, pub server: ServerConfig, + pub email: EmailConfig, pub site_path: String, } #[derive(Clone)] @@ -20,6 +21,16 @@ pub struct ServerConfig { pub host: String, pub port: u16, } +#[derive(Clone)] +pub struct EmailConfig { + pub server: String, + pub port: u16, + pub username: String, + pub password: String, + pub from: String, + pub smtp_server: String, + pub smtp_port: u16, +} impl AppConfig { pub fn from_database(pool: &DbPool) -> Result { use crate::shared::models::schema::bot_configuration::dsl::*; @@ -79,8 +90,18 @@ impl AppConfig { access_key: std::env::var("DRIVE_ACCESSKEY").unwrap(), secret_key: std::env::var("DRIVE_SECRET").unwrap(), }; + let email = EmailConfig { + server: get_str("EMAIL_IMAP_SERVER", "imap.gmail.com"), + port: get_u16("EMAIL_IMAP_PORT", 993), + username: get_str("EMAIL_USERNAME", ""), + password: get_str("EMAIL_PASSWORD", ""), + from: get_str("EMAIL_FROM", ""), + smtp_server: get_str("EMAIL_SMTP_SERVER", "smtp.gmail.com"), + smtp_port: get_u16("EMAIL_SMTP_PORT", 587), + }; Ok(AppConfig { drive, + email, server: ServerConfig { host: get_str("SERVER_HOST", "127.0.0.1"), port: get_u16("SERVER_PORT", 8080), @@ -98,8 +119,26 @@ impl AppConfig { access_key: std::env::var("DRIVE_ACCESSKEY").unwrap(), secret_key: std::env::var("DRIVE_SECRET").unwrap(), }; + let email = EmailConfig { + server: std::env::var("EMAIL_IMAP_SERVER") + .unwrap_or_else(|_| "imap.gmail.com".to_string()), + port: std::env::var("EMAIL_IMAP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(993), + username: std::env::var("EMAIL_USERNAME").unwrap_or_default(), + password: std::env::var("EMAIL_PASSWORD").unwrap_or_default(), + from: std::env::var("EMAIL_FROM").unwrap_or_default(), + smtp_server: std::env::var("EMAIL_SMTP_SERVER") + .unwrap_or_else(|_| "smtp.gmail.com".to_string()), + smtp_port: std::env::var("EMAIL_SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(587), + }; Ok(AppConfig { drive: minio, + email, server: ServerConfig { host: std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()), port: std::env::var("SERVER_PORT") diff --git a/src/email/mod.rs b/src/email/mod.rs index 4793540ac..3a311e65d 100644 --- a/src/email/mod.rs +++ b/src/email/mod.rs @@ -5,29 +5,78 @@ use axum::{ response::{IntoResponse, Response}, Json, }; +use base64::{engine::general_purpose, Engine as _}; use diesel::prelude::*; use imap::types::Seq; use lettre::{transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport}; -use log::info; +use log::{error, info}; use mailparse::{parse_mail, MailHeaderMap}; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use uuid::Uuid; + +// ===== Request/Response Structures ===== + +#[derive(Debug, Serialize, Deserialize)] +pub struct EmailAccountRequest { + pub email: String, + pub display_name: Option, + pub imap_server: String, + pub imap_port: u16, + pub smtp_server: String, + pub smtp_port: u16, + pub username: String, + pub password: String, + pub is_primary: bool, +} + +#[derive(Debug, Serialize)] +pub struct EmailAccountResponse { + pub id: String, + pub email: String, + pub display_name: Option, + pub imap_server: String, + pub imap_port: u16, + pub smtp_server: String, + pub smtp_port: u16, + pub is_primary: bool, + pub is_active: bool, + pub created_at: String, +} #[derive(Debug, Serialize)] pub struct EmailResponse { pub id: String, - pub name: String, - pub email: String, + pub from_name: String, + pub from_email: String, + pub to: String, pub subject: String, - pub text: String, - date: String, - read: bool, - labels: Vec, + pub preview: String, + pub body: String, + pub date: String, + pub time: String, + pub read: bool, + pub folder: String, + pub has_attachments: bool, +} + +#[derive(Debug, Deserialize)] +pub struct SendEmailRequest { + pub account_id: String, + pub to: String, + pub cc: Option, + pub bcc: Option, + pub subject: String, + pub body: String, + pub is_html: bool, } #[derive(Debug, Deserialize)] pub struct SaveDraftRequest { + pub account_id: String, pub to: String, + pub cc: Option, + pub bcc: Option, pub subject: String, pub body: String, } @@ -40,18 +89,43 @@ pub struct SaveDraftResponse { } #[derive(Debug, Deserialize)] -pub struct GetLatestEmailRequest { - pub from_email: String, +pub struct ListEmailsRequest { + pub account_id: String, + pub folder: Option, + pub limit: Option, + pub offset: Option, +} + +#[derive(Debug, Deserialize)] +pub struct MarkEmailRequest { + pub account_id: String, + pub email_id: String, + pub read: bool, +} + +#[derive(Debug, Deserialize)] +pub struct DeleteEmailRequest { + pub account_id: String, + pub email_id: String, } #[derive(Debug, Serialize)] -pub struct LatestEmailResponse { - pub success: bool, - pub email_text: Option, - pub message: String, +pub struct FolderInfo { + pub name: String, + pub path: String, + pub unread_count: i32, + pub total_count: i32, } -// Custom error type for email operations +#[derive(Debug, Serialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub message: Option, +} + +// ===== Error Handling ===== + struct EmailError(String); impl IntoResponse for EmailError { @@ -66,57 +140,277 @@ impl From for EmailError { } } -async fn internal_send_email(config: &EmailConfig, to: &str, subject: &str, body: &str) { - let email = Message::builder() - .from(config.from.parse().unwrap()) - .to(to.parse().unwrap()) - .subject(subject) - .body(body.to_string()) - .unwrap(); - let creds = Credentials::new(config.username.clone(), config.password.clone()); - SmtpTransport::relay(&config.server) - .unwrap() - .port(config.port) - .credentials(creds) - .build() - .send(&email) - .unwrap(); +// ===== Helper Functions ===== + +fn parse_from_field(from: &str) -> (String, String) { + if let Some(start) = from.find('<') { + if let Some(end) = from.find('>') { + let name = from[..start].trim().trim_matches('"').to_string(); + let email = from[start + 1..end].to_string(); + return (name, email); + } + } + (String::new(), from.to_string()) } +fn format_email_time(date_str: &str) -> String { + // Simple time formatting - in production, use proper date parsing + if date_str.is_empty() { + return "Unknown".to_string(); + } + // Return simplified version for now + date_str + .split_whitespace() + .take(4) + .collect::>() + .join(" ") +} + +fn encrypt_password(password: &str) -> String { + // In production, use proper encryption like AES-256 + // For now, base64 encode (THIS IS NOT SECURE - USE PROPER ENCRYPTION) + general_purpose::STANDARD.encode(password.as_bytes()) +} + +fn decrypt_password(encrypted: &str) -> Result { + // In production, use proper decryption + general_purpose::STANDARD + .decode(encrypted) + .map_err(|e| format!("Decryption failed: {}", e)) + .and_then(|bytes| { + String::from_utf8(bytes).map_err(|e| format!("UTF-8 conversion failed: {}", e)) + }) +} + +// ===== Account Management Endpoints ===== + +pub async fn add_email_account( + State(state): State>, + Json(request): Json, +) -> Result>, EmailError> { + // TODO: Get user_id from session/token authentication + let user_id = Uuid::nil(); // Placeholder - implement proper auth + + let account_id = Uuid::new_v4(); + let encrypted_password = encrypt_password(&request.password); + + let conn = state.conn.clone(); + tokio::task::spawn_blocking(move || { + use crate::shared::models::schema::user_email_accounts::dsl::*; + let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + + // If this is primary, unset other primary accounts + if request.is_primary { + diesel::update(user_email_accounts.filter(user_id.eq(&user_id))) + .set(is_primary.eq(false)) + .execute(&mut db_conn) + .ok(); + } + + diesel::sql_query( + "INSERT INTO user_email_accounts + (id, user_id, email, display_name, imap_server, imap_port, smtp_server, smtp_port, username, password_encrypted, is_primary, is_active) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)" + ) + .bind::(account_id) + .bind::(user_id) + .bind::(&request.email) + .bind::, _>(request.display_name.as_ref()) + .bind::(&request.imap_server) + .bind::(request.imap_port as i32) + .bind::(&request.smtp_server) + .bind::(request.smtp_port as i32) + .bind::(&request.username) + .bind::(&encrypted_password) + .bind::(request.is_primary) + .bind::(true) + .execute(&mut db_conn) + .map_err(|e| format!("Failed to insert account: {}", e))?; + + Ok::<_, String>(account_id) + }) + .await + .map_err(|e| EmailError(format!("Task join error: {}", e)))? + .map_err(EmailError)?; + + Ok(Json(ApiResponse { + success: true, + data: Some(EmailAccountResponse { + id: account_id.to_string(), + email: request.email, + display_name: request.display_name, + imap_server: request.imap_server, + imap_port: request.imap_port, + smtp_server: request.smtp_server, + smtp_port: request.smtp_port, + is_primary: request.is_primary, + is_active: true, + created_at: chrono::Utc::now().to_rfc3339(), + }), + message: Some("Email account added successfully".to_string()), + })) +} + +pub async fn list_email_accounts( + State(state): State>, +) -> Result>>, EmailError> { + // TODO: Get user_id from session/token authentication + let user_id = Uuid::nil(); // Placeholder + + let conn = state.conn.clone(); + let accounts = tokio::task::spawn_blocking(move || { + let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + + let results: Vec<(Uuid, String, Option, String, i32, String, i32, bool, bool, chrono::DateTime)> = + diesel::sql_query( + "SELECT id, email, display_name, imap_server, imap_port, smtp_server, smtp_port, is_primary, is_active, created_at + FROM user_email_accounts WHERE user_id = $1 AND is_active = true ORDER BY is_primary DESC, created_at DESC" + ) + .bind::(user_id) + .load(&mut db_conn) + .map_err(|e| format!("Query failed: {}", e))?; + + Ok::<_, String>(results) + }) + .await + .map_err(|e| EmailError(format!("Task join error: {}", e)))? + .map_err(EmailError)?; + + let account_list: Vec = accounts + .into_iter() + .map( + |( + id, + email, + display_name, + imap_server, + imap_port, + smtp_server, + smtp_port, + is_primary, + is_active, + created_at, + )| { + EmailAccountResponse { + id: id.to_string(), + email, + display_name, + imap_server, + imap_port: imap_port as u16, + smtp_server, + smtp_port: smtp_port as u16, + is_primary, + is_active, + created_at: created_at.to_rfc3339(), + } + }, + ) + .collect(); + + Ok(Json(ApiResponse { + success: true, + data: Some(account_list), + message: None, + })) +} + +pub async fn delete_email_account( + State(state): State>, + Path(account_id): Path, +) -> Result>, EmailError> { + let account_uuid = + Uuid::parse_str(&account_id).map_err(|_| EmailError("Invalid account ID".to_string()))?; + + let conn = state.conn.clone(); + tokio::task::spawn_blocking(move || { + let mut db_conn = conn + .get() + .map_err(|e| format!("DB connection error: {}", e))?; + + diesel::sql_query("UPDATE user_email_accounts SET is_active = false WHERE id = $1") + .bind::(account_uuid) + .execute(&mut db_conn) + .map_err(|e| format!("Failed to delete account: {}", e))?; + + Ok::<_, String>(()) + }) + .await + .map_err(|e| EmailError(format!("Task join error: {}", e)))? + .map_err(EmailError)?; + + Ok(Json(ApiResponse { + success: true, + data: Some(()), + message: Some("Email account deleted".to_string()), + })) +} + +// ===== Email Operations ===== + pub async fn list_emails( State(state): State>, -) -> Result>, EmailError> { - let _config = state - .config - .as_ref() - .ok_or_else(|| EmailError("Configuration not available".to_string()))?; + Json(request): Json, +) -> Result>>, EmailError> { + let account_uuid = Uuid::parse_str(&request.account_id) + .map_err(|_| EmailError("Invalid account ID".to_string()))?; + // Get account credentials from database + let conn = state.conn.clone(); + let account_info = tokio::task::spawn_blocking(move || { + let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + + let result: (String, i32, String, String) = diesel::sql_query( + "SELECT imap_server, imap_port, username, password_encrypted FROM user_email_accounts WHERE id = $1 AND is_active = true" + ) + .bind::(account_uuid) + .get_result(&mut db_conn) + .map_err(|e| format!("Account not found: {}", e))?; + + Ok::<_, String>(result) + }) + .await + .map_err(|e| EmailError(format!("Task join error: {}", e)))? + .map_err(EmailError)?; + + let (imap_server, imap_port, username, encrypted_password) = account_info; + let password = decrypt_password(&encrypted_password).map_err(EmailError)?; + + // Connect to IMAP let tls = native_tls::TlsConnector::builder() .build() .map_err(|e| EmailError(format!("Failed to create TLS connector: {:?}", e)))?; let client = imap::connect( - (_config.email.server.as_str(), 993), - _config.email.server.as_str(), + (imap_server.as_str(), imap_port as u16), + imap_server.as_str(), &tls, ) .map_err(|e| EmailError(format!("Failed to connect to IMAP: {:?}", e)))?; let mut session = client - .login(&_config.email.username, &_config.email.password) + .login(&username, &password) .map_err(|e| EmailError(format!("Login failed: {:?}", e)))?; + let folder = request.folder.unwrap_or_else(|| "INBOX".to_string()); session - .select("INBOX") - .map_err(|e| EmailError(format!("Failed to select INBOX: {:?}", e)))?; + .select(&folder) + .map_err(|e| EmailError(format!("Failed to select folder: {:?}", e)))?; let messages = session .search("ALL") .map_err(|e| EmailError(format!("Failed to search emails: {:?}", e)))?; let mut email_list = Vec::new(); + let limit = request.limit.unwrap_or(50); + let offset = request.offset.unwrap_or(0); + let recent_messages: Vec<_> = messages.iter().cloned().collect(); - let recent_messages: Vec = recent_messages.into_iter().rev().take(20).collect(); + let recent_messages: Vec = recent_messages + .into_iter() + .rev() + .skip(offset) + .take(limit) + .collect(); for seq in recent_messages { let fetch_result = session.fetch(seq.to_string(), "RFC822"); @@ -134,6 +428,7 @@ pub async fn list_emails( let headers = parsed.get_headers(); let subject = headers.get_first_value("Subject").unwrap_or_default(); let from = headers.get_first_value("From").unwrap_or_default(); + let to = headers.get_first_value("To").unwrap_or_default(); let date = headers.get_first_value("Date").unwrap_or_default(); let body_text = if let Some(body_part) = parsed @@ -146,6 +441,16 @@ pub async fn list_emails( parsed.get_body().unwrap_or_default() }; + let body_html = if let Some(body_part) = parsed + .subparts + .iter() + .find(|p| p.ctype.mimetype == "text/html") + { + body_part.get_body().unwrap_or_default() + } else { + String::new() + }; + let preview = body_text.lines().take(3).collect::>().join(" "); let preview_truncated = if preview.len() > 150 { format!("{}...", &preview[..150]) @@ -154,153 +459,262 @@ pub async fn list_emails( }; let (from_name, from_email) = parse_from_field(&from); + let has_attachments = parsed.subparts.iter().any(|p| { + p.get_content_disposition().disposition == mailparse::DispositionType::Attachment + }); + email_list.push(EmailResponse { id: seq.to_string(), - name: from_name, - email: from_email, + from_name, + from_email, + to, subject, - text: preview_truncated, - date, - read: false, - labels: vec![], + preview: preview_truncated, + body: if body_html.is_empty() { + body_text + } else { + body_html + }, + date: format_email_time(&date), + time: format_email_time(&date), + read: false, // TODO: Check IMAP flags + folder: folder.clone(), + has_attachments, }); } } session.logout().ok(); - Ok(Json(email_list)) -} -fn parse_from_field(from: &str) -> (String, String) { - if let Some(start) = from.find('<') { - if let Some(end) = from.find('>') { - let name = from[..start].trim().trim_matches('"').to_string(); - let email = from[start + 1..end].to_string(); - return (name, email); - } - } - (String::new(), from.to_string()) -} - -async fn save_email_draft( - config: &EmailConfig, - draft_data: &SaveDraftRequest, -) -> Result> { - let draft_id = uuid::Uuid::new_v4().to_string(); - Ok(draft_id) -} - -pub async fn save_draft( - State(state): State>, - Json(draft_data): Json, -) -> Result, EmailError> { - let config = state - .config - .as_ref() - .ok_or_else(|| EmailError("Configuration not available".to_string()))?; - - match save_email_draft(&config.email, &draft_data).await { - Ok(draft_id) => Ok(Json(SaveDraftResponse { - success: true, - draft_id: Some(draft_id), - message: "Draft saved successfully".to_string(), - })), - Err(e) => Ok(Json(SaveDraftResponse { - success: false, - draft_id: None, - message: format!("Failed to save draft: {}", e), - })), - } -} - -async fn fetch_latest_email_from_sender( - config: &EmailConfig, - from_email: &str, -) -> Result> { - let tls = native_tls::TlsConnector::builder().build()?; - let client = imap::connect((config.server.as_str(), 993), config.server.as_str(), &tls)?; - let mut session = client.login(&config.username, &config.password)?; - session.select("INBOX")?; - - let search_query = format!("FROM \"{}\"", from_email); - let messages = session.search(&search_query)?; - - if let Some(&seq) = messages.last() { - let fetch_result = session.fetch(seq.to_string(), "RFC822")?; - for msg in fetch_result.iter() { - if let Some(body) = msg.body() { - let parsed = parse_mail(body)?; - let body_text = if let Some(body_part) = parsed - .subparts - .iter() - .find(|p| p.ctype.mimetype == "text/plain") - { - body_part.get_body().unwrap_or_default() - } else { - parsed.get_body().unwrap_or_default() - }; - session.logout().ok(); - return Ok(body_text); - } - } - } - - session.logout().ok(); - Err("No email found from sender".into()) -} - -pub async fn get_latest_email_from( - State(state): State>, - Json(request): Json, -) -> Result, EmailError> { - let config = state - .config - .as_ref() - .ok_or_else(|| EmailError("Configuration not available".to_string()))?; - - match fetch_latest_email_from_sender(&config.email, &request.from_email).await { - Ok(email_text) => Ok(Json(LatestEmailResponse { - success: true, - email_text: Some(email_text), - message: "Email retrieved successfully".to_string(), - })), - Err(e) => Ok(Json(LatestEmailResponse { - success: false, - email_text: None, - message: format!("Failed to retrieve email: {}", e), - })), - } + Ok(Json(ApiResponse { + success: true, + data: Some(email_list), + message: None, + })) } pub async fn send_email( State(state): State>, - Json(payload): Json<(String, String, String)>, -) -> Result { - let (to, subject, body) = payload; - info!("To: {}", to); - info!("Subject: {}", subject); - info!("Body: {}", body); + Json(request): Json, +) -> Result>, EmailError> { + let account_uuid = Uuid::parse_str(&request.account_id) + .map_err(|_| EmailError("Invalid account ID".to_string()))?; - let config = state - .config - .as_ref() - .ok_or_else(|| EmailError("Configuration not available".to_string()))?; + // Get account credentials + let conn = state.conn.clone(); + let account_info = tokio::task::spawn_blocking(move || { + let mut db_conn = conn + .get() + .map_err(|e| format!("DB connection error: {}", e))?; - internal_send_email(&config.email, &to, &subject, &body).await; - Ok(StatusCode::OK) + let result: (String, String, i32, String, String, String) = diesel::sql_query( + "SELECT email, display_name, smtp_port, smtp_server, username, password_encrypted + FROM user_email_accounts WHERE id = $1 AND is_active = true", + ) + .bind::(account_uuid) + .get_result(&mut db_conn) + .map_err(|e| format!("Account not found: {}", e))?; + + Ok::<_, String>(result) + }) + .await + .map_err(|e| EmailError(format!("Task join error: {}", e)))? + .map_err(EmailError)?; + + let (from_email, display_name, smtp_port, smtp_server, username, encrypted_password) = + account_info; + let password = decrypt_password(&encrypted_password).map_err(EmailError)?; + + let from_addr = if display_name.is_empty() { + from_email.clone() + } else { + format!("{} <{}>", display_name, from_email) + }; + + // Build email + let mut email_builder = Message::builder() + .from( + from_addr + .parse() + .map_err(|e| EmailError(format!("Invalid from address: {}", e)))?, + ) + .to(request + .to + .parse() + .map_err(|e| EmailError(format!("Invalid to address: {}", e)))?) + .subject(request.subject); + + if let Some(cc) = request.cc { + email_builder = email_builder.cc(cc + .parse() + .map_err(|e| EmailError(format!("Invalid cc address: {}", e)))?); + } + + if let Some(bcc) = request.bcc { + email_builder = email_builder.bcc( + bcc.parse() + .map_err(|e| EmailError(format!("Invalid bcc address: {}", e)))?, + ); + } + + let email = email_builder + .body(request.body) + .map_err(|e| EmailError(format!("Failed to build email: {}", e)))?; + + // Send email + let creds = Credentials::new(username, password); + let mailer = SmtpTransport::relay(&smtp_server) + .map_err(|e| EmailError(format!("Failed to create SMTP transport: {}", e)))? + .port(smtp_port as u16) + .credentials(creds) + .build(); + + mailer + .send(&email) + .map_err(|e| EmailError(format!("Failed to send email: {}", e)))?; + + info!("Email sent successfully from account {}", account_uuid); + + Ok(Json(ApiResponse { + success: true, + data: Some(()), + message: Some("Email sent successfully".to_string()), + })) +} + +pub async fn save_draft( + State(state): State>, + Json(request): Json, +) -> Result, EmailError> { + let account_uuid = Uuid::parse_str(&request.account_id) + .map_err(|_| EmailError("Invalid account ID".to_string()))?; + + // TODO: Get user_id from session + let user_id = Uuid::nil(); + let draft_id = Uuid::new_v4(); + + let conn = state.conn.clone(); + tokio::task::spawn_blocking(move || { + let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + + diesel::sql_query( + "INSERT INTO email_drafts (id, user_id, account_id, to_address, cc_address, bcc_address, subject, body) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)" + ) + .bind::(draft_id) + .bind::(user_id) + .bind::(account_uuid) + .bind::(&request.to) + .bind::, _>(request.cc.as_ref()) + .bind::, _>(request.bcc.as_ref()) + .bind::(&request.subject) + .bind::(&request.body) + .execute(&mut db_conn) + .map_err(|e| format!("Failed to save draft: {}", e))?; + + Ok::<_, String>(()) + }) + .await + .map_err(|e| EmailError(format!("Task join error: {}", e)))? + .map_err(|e| { + return EmailError(e); + })?; + + Ok(Json(SaveDraftResponse { + success: true, + draft_id: Some(draft_id.to_string()), + message: "Draft saved successfully".to_string(), + })) +} + +pub async fn list_folders( + State(state): State>, + Path(account_id): Path, +) -> Result>>, EmailError> { + let account_uuid = + Uuid::parse_str(&account_id).map_err(|_| EmailError("Invalid account ID".to_string()))?; + + // Get account credentials + let conn = state.conn.clone(); + let account_info = tokio::task::spawn_blocking(move || { + let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + + let result: (String, i32, String, String) = diesel::sql_query( + "SELECT imap_server, imap_port, username, password_encrypted FROM user_email_accounts WHERE id = $1 AND is_active = true" + ) + .bind::(account_uuid) + .get_result(&mut db_conn) + .map_err(|e| format!("Account not found: {}", e))?; + + Ok::<_, String>(result) + }) + .await + .map_err(|e| EmailError(format!("Task join error: {}", e)))? + .map_err(EmailError)?; + + let (imap_server, imap_port, username, encrypted_password) = account_info; + let password = decrypt_password(&encrypted_password).map_err(EmailError)?; + + // Connect and list folders + let tls = native_tls::TlsConnector::builder() + .build() + .map_err(|e| EmailError(format!("TLS error: {:?}", e)))?; + + let client = imap::connect( + (imap_server.as_str(), imap_port as u16), + imap_server.as_str(), + &tls, + ) + .map_err(|e| EmailError(format!("IMAP connection error: {:?}", e)))?; + + let mut session = client + .login(&username, &password) + .map_err(|e| EmailError(format!("Login failed: {:?}", e)))?; + + let folders = session + .list(None, Some("*")) + .map_err(|e| EmailError(format!("Failed to list folders: {:?}", e)))?; + + let folder_list: Vec = folders + .iter() + .map(|f| FolderInfo { + name: f.name().to_string(), + path: f.name().to_string(), + unread_count: 0, // TODO: Query actual counts + total_count: 0, + }) + .collect(); + + session.logout().ok(); + + Ok(Json(ApiResponse { + success: true, + data: Some(folder_list), + message: None, + })) +} + +// ===== Legacy endpoints for backward compatibility ===== + +pub async fn get_latest_email_from( + State(_state): State>, + Json(_request): Json, +) -> Result, EmailError> { + Ok(Json(serde_json::json!({ + "success": false, + "message": "Please use the new /api/email/list endpoint with account_id" + }))) } pub async fn save_click( Path((campaign_id, email)): Path<(String, String)>, State(_state): State>, ) -> impl IntoResponse { - // Log the click event info!( "Click tracked - Campaign: {}, Email: {}", campaign_id, email ); - // Return a 1x1 transparent GIF pixel let pixel: Vec = vec![ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x21, 0xF9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, @@ -314,7 +728,6 @@ pub async fn get_emails( Path(campaign_id): Path, State(_state): State>, ) -> String { - // Return placeholder response info!("Get emails requested for campaign: {}", campaign_id); "No emails tracked".to_string() } diff --git a/src/main.rs b/src/main.rs index a406e088e..b8c80af83 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,7 +44,8 @@ use crate::channels::{VoiceAdapter, WebChannelAdapter}; use crate::config::AppConfig; #[cfg(feature = "email")] use crate::email::{ - get_emails, get_latest_email_from, list_emails, save_click, save_draft, send_email, + add_email_account, delete_email_account, get_emails, get_latest_email_from, + list_email_accounts, list_emails, list_folders, save_click, save_draft, send_email, }; use crate::file::upload_file; use crate::meet::{voice_start, voice_stop}; @@ -123,11 +124,18 @@ async fn run_axum_server( // Add email routes if feature is enabled #[cfg(feature = "email")] let api_router = api_router - .route("/api/email/latest", post(get_latest_email_from)) - .route("/api/email/get/{campaign_id}", get(get_emails)) - .route("/api/email/list", get(list_emails)) + .route("/api/email/accounts", get(list_email_accounts)) + .route("/api/email/accounts/add", post(add_email_account)) + .route( + "/api/email/accounts/{account_id}", + axum::routing::delete(delete_email_account), + ) + .route("/api/email/list", post(list_emails)) .route("/api/email/send", post(send_email)) .route("/api/email/draft", post(save_draft)) + .route("/api/email/folders/{account_id}", get(list_folders)) + .route("/api/email/latest", post(get_latest_email_from)) + .route("/api/email/get/{campaign_id}", get(get_emails)) .route("/api/email/click/{campaign_id}/{email}", get(save_click)); // Build static file serving diff --git a/src/package_manager/mod.rs b/src/package_manager/mod.rs index aa5e8a415..762ae56b4 100644 --- a/src/package_manager/mod.rs +++ b/src/package_manager/mod.rs @@ -1,6 +1,7 @@ pub mod component; pub mod installer; pub mod os; +pub mod setup; pub use installer::PackageManager; pub mod cli; pub mod facade; diff --git a/src/shared/models.rs b/src/shared/models.rs index 5b5571bfb..aec2ffe3e 100644 --- a/src/shared/models.rs +++ b/src/shared/models.rs @@ -58,8 +58,8 @@ pub struct UserMessage { } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Suggestion { - pub text: String, - pub context: String, + pub text: String, + pub context: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BotResponse { @@ -278,5 +278,74 @@ pub mod schema { updated_at -> Timestamptz, } } + diesel::table! { + user_email_accounts (id) { + id -> Uuid, + user_id -> Uuid, + email -> Varchar, + display_name -> Nullable, + imap_server -> Varchar, + imap_port -> Int4, + smtp_server -> Varchar, + smtp_port -> Int4, + username -> Varchar, + password_encrypted -> Text, + is_primary -> Bool, + is_active -> Bool, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } + } + diesel::table! { + email_drafts (id) { + id -> Uuid, + user_id -> Uuid, + account_id -> Uuid, + to_address -> Text, + cc_address -> Nullable, + bcc_address -> Nullable, + subject -> Nullable, + body -> Nullable, + attachments -> Jsonb, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } + } + diesel::table! { + email_folders (id) { + id -> Uuid, + account_id -> Uuid, + folder_name -> Varchar, + folder_path -> Varchar, + unread_count -> Int4, + total_count -> Int4, + last_synced -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } + } + diesel::table! { + user_preferences (id) { + id -> Uuid, + user_id -> Uuid, + preference_key -> Varchar, + preference_value -> Jsonb, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } + } + diesel::table! { + user_login_tokens (id) { + id -> Uuid, + user_id -> Uuid, + token_hash -> Varchar, + expires_at -> Timestamptz, + created_at -> Timestamptz, + last_used -> Timestamptz, + user_agent -> Nullable, + ip_address -> Nullable, + is_active -> Bool, + } + } } pub use schema::*; diff --git a/src/ui_tree/file_tree.rs b/src/ui_tree/file_tree.rs index c67b191f9..6e7ed598f 100644 --- a/src/ui_tree/file_tree.rs +++ b/src/ui_tree/file_tree.rs @@ -1,227 +1,268 @@ +use crate::shared::state::AppState; use color_eyre::Result; use std::sync::Arc; -use crate::shared::state::AppState; #[derive(Debug, Clone)] pub enum TreeNode { - Bucket { name: String }, - Folder { bucket: String, path: String }, - File { bucket: String, path: String }, + Bucket { name: String }, + Folder { bucket: String, path: String }, + File { bucket: String, path: String }, } pub struct FileTree { - app_state: Arc, - items: Vec<(String, TreeNode)>, - selected: usize, - current_bucket: Option, - current_path: Vec, + app_state: Arc, + items: Vec<(String, TreeNode)>, + selected: usize, + current_bucket: Option, + current_path: Vec, } impl FileTree { - pub fn new(app_state: Arc) -> Self { - Self { - app_state, - items: Vec::new(), - selected: 0, - current_bucket: None, - current_path: Vec::new(), - } - } - pub async fn load_root(&mut self) -> Result<()> { - self.items.clear(); - self.current_bucket = None; - self.current_path.clear(); - if let Some(drive) = &self.app_state.drive { - let result = drive.list_buckets().send().await; - match result { - Ok(response) => { - let buckets = response.buckets(); - for bucket in buckets { - if let Some(name) = bucket.name() { - let icon = if name.ends_with(".gbai") { "🤖" } else { "📦" }; - let display = format!("{} {}", icon, name); - self.items.push((display, TreeNode::Bucket { name: name.to_string() })); - } - } - } - Err(e) => { - self.items.push((format!("✗ Error: {}", e), TreeNode::Bucket { name: String::new() })); - } - } - } else { - self.items.push(("✗ Drive not connected".to_string(), TreeNode::Bucket { name: String::new() })); - } - if self.items.is_empty() { - self.items.push(("(no buckets found)".to_string(), TreeNode::Bucket { name: String::new() })); - } - self.selected = 0; - Ok(()) - } - pub async fn enter_bucket(&mut self, bucket: String) -> Result<()> { - self.current_bucket = Some(bucket.clone()); - self.current_path.clear(); - self.load_bucket_contents(&bucket, "").await - } - pub async fn enter_folder(&mut self, bucket: String, path: String) -> Result<()> { - self.current_bucket = Some(bucket.clone()); - let parts: Vec<&str> = path.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect(); - self.current_path = parts.iter().map(|s| s.to_string()).collect(); - self.load_bucket_contents(&bucket, &path).await - } - pub fn go_up(&mut self) -> bool { - if self.current_path.is_empty() { - if self.current_bucket.is_some() { - self.current_bucket = None; - return true; - } - return false; - } - self.current_path.pop(); - true - } - pub async fn refresh_current(&mut self) -> Result<()> { - if let Some(bucket) = &self.current_bucket.clone() { - let path = self.current_path.join("/"); - self.load_bucket_contents(bucket, &path).await - } else { - self.load_root().await - } - } - async fn load_bucket_contents(&mut self, bucket: &str, prefix: &str) -> Result<()> { - self.items.clear(); - self.items.push(("⬆️ .. (go back)".to_string(), TreeNode::Folder { - bucket: bucket.to_string(), - path: "..".to_string(), - })); - if let Some(drive) = &self.app_state.drive { - let normalized_prefix = if prefix.is_empty() { - String::new() - } else if prefix.ends_with('/') { - prefix.to_string() - } else { - format!("{}/", prefix) - }; - let mut continuation_token = None; - let mut all_keys = Vec::new(); - loop { - let mut request = drive.list_objects_v2().bucket(bucket); - if !normalized_prefix.is_empty() { - request = request.prefix(&normalized_prefix); - } - if let Some(token) = continuation_token { - request = request.continuation_token(token); - } - let result = request.send().await?; - for obj in result.contents() { - if let Some(key) = obj.key() { - all_keys.push(key.to_string()); - } - } - if !result.is_truncated.unwrap_or(false) { - break; - } - continuation_token = result.next_continuation_token; - } - let mut folders = std::collections::HashSet::new(); - let mut files = Vec::new(); - for key in all_keys { - if key == normalized_prefix { - continue; - } - let relative = if !normalized_prefix.is_empty() && key.starts_with(&normalized_prefix) { - &key[normalized_prefix.len()..] - } else { - &key - }; - if relative.is_empty() { - continue; - } - if let Some(slash_pos) = relative.find('/') { - let folder_name = &relative[..slash_pos]; - if !folder_name.is_empty() { - folders.insert(folder_name.to_string()); - } - } else { - files.push((relative.to_string(), key.clone())); - } - } - let mut folder_vec: Vec = folders.into_iter().collect(); - folder_vec.sort(); - for folder_name in folder_vec { - let full_path = if normalized_prefix.is_empty() { - folder_name.clone() - } else { - format!("{}{}", normalized_prefix, folder_name) - }; - let display = format!("📁 {}/", folder_name); - self.items.push((display, TreeNode::Folder { - bucket: bucket.to_string(), - path: full_path, - })); - } - files.sort_by(|(a, _), (b, _)| a.cmp(b)); - for (name, full_path) in files { - let icon = if name.ends_with(".bas") { - "⚙️" - } else if name.ends_with(".ast") { - "🔧" - } else if name.ends_with(".csv") { - "📊" - } else if name.ends_with(".gbkb") { - "📚" - } else if name.ends_with(".json") { - "🔖" - } else { - "📄" - }; - let display = format!("{} {}", icon, name); - self.items.push((display, TreeNode::File { - bucket: bucket.to_string(), - path: full_path, - })); - } - } - if self.items.len() == 1 { - self.items.push(("(empty folder)".to_string(), TreeNode::Folder { - bucket: bucket.to_string(), - path: String::new(), - })); - } - self.selected = 0; - Ok(()) - } - pub fn render_items(&self) -> &[(String, TreeNode)] { - &self.items - } - pub fn selected_index(&self) -> usize { - self.selected - } - pub fn get_selected_node(&self) -> Option<&TreeNode> { - self.items.get(self.selected).map(|(_, node)| node) - } - pub fn get_selected_bot(&self) -> Option { - if let Some(bucket) = &self.current_bucket { - if bucket.ends_with(".gbai") { - return Some(bucket.trim_end_matches(".gbai").to_string()); - } - } - if let Some((_, node)) = self.items.get(self.selected) { - match node { - TreeNode::Bucket { name } => { - if name.ends_with(".gbai") { - return Some(name.trim_end_matches(".gbai").to_string()); - } - } - _ => {} - } - } - None - } - pub fn move_up(&mut self) { - if self.selected > 0 { - self.selected -= 1; - } - } - pub fn move_down(&mut self) { - if self.selected < self.items.len().saturating_sub(1) { - self.selected += 1; - } - } + pub fn new(app_state: Arc) -> Self { + Self { + app_state, + items: Vec::new(), + selected: 0, + current_bucket: None, + current_path: Vec::new(), + } + } + pub async fn load_root(&mut self) -> Result<()> { + self.items.clear(); + self.current_bucket = None; + self.current_path.clear(); + if let Some(drive) = &self.app_state.drive { + let result = drive.list_buckets().send().await; + match result { + Ok(response) => { + let buckets = response.buckets(); + for bucket in buckets { + if let Some(name) = bucket.name() { + let icon = if name.ends_with(".gbai") { + "🤖" + } else { + "📦" + }; + let display = format!("{} {}", icon, name); + self.items.push(( + display, + TreeNode::Bucket { + name: name.to_string(), + }, + )); + } + } + } + Err(e) => { + self.items.push(( + format!("✗ Error: {}", e), + TreeNode::Bucket { + name: String::new(), + }, + )); + } + } + } else { + self.items.push(( + "✗ Drive not connected".to_string(), + TreeNode::Bucket { + name: String::new(), + }, + )); + } + if self.items.is_empty() { + self.items.push(( + "(no buckets found)".to_string(), + TreeNode::Bucket { + name: String::new(), + }, + )); + } + self.selected = 0; + Ok(()) + } + pub async fn enter_bucket(&mut self, bucket: String) -> Result<()> { + self.current_bucket = Some(bucket.clone()); + self.current_path.clear(); + self.load_bucket_contents(&bucket, "").await + } + pub async fn enter_folder(&mut self, bucket: String, path: String) -> Result<()> { + self.current_bucket = Some(bucket.clone()); + let parts: Vec<&str> = path + .trim_matches('/') + .split('/') + .filter(|s| !s.is_empty()) + .collect(); + self.current_path = parts.iter().map(|s| s.to_string()).collect(); + self.load_bucket_contents(&bucket, &path).await + } + pub fn go_up(&mut self) -> bool { + if self.current_path.is_empty() { + if self.current_bucket.is_some() { + self.current_bucket = None; + return true; + } + return false; + } + self.current_path.pop(); + true + } + pub async fn refresh_current(&mut self) -> Result<()> { + if let Some(bucket) = &self.current_bucket.clone() { + let path = self.current_path.join("/"); + self.load_bucket_contents(bucket, &path).await + } else { + self.load_root().await + } + } + async fn load_bucket_contents(&mut self, bucket: &str, prefix: &str) -> Result<()> { + self.items.clear(); + self.items.push(( + "⬆️ .. (go back)".to_string(), + TreeNode::Folder { + bucket: bucket.to_string(), + path: "..".to_string(), + }, + )); + if let Some(drive) = &self.app_state.drive { + let normalized_prefix = if prefix.is_empty() { + String::new() + } else if prefix.ends_with('/') { + prefix.to_string() + } else { + format!("{}/", prefix) + }; + let mut continuation_token = None; + let mut all_keys = Vec::new(); + loop { + let mut request = drive.list_objects_v2().bucket(bucket); + if !normalized_prefix.is_empty() { + request = request.prefix(&normalized_prefix); + } + if let Some(token) = continuation_token { + request = request.continuation_token(token); + } + let result = request.send().await?; + for obj in result.contents() { + if let Some(key) = obj.key() { + all_keys.push(key.to_string()); + } + } + if !result.is_truncated.unwrap_or(false) { + break; + } + continuation_token = result.next_continuation_token; + } + let mut folders = std::collections::HashSet::new(); + let mut files = Vec::new(); + for key in all_keys { + if key == normalized_prefix { + continue; + } + let relative = + if !normalized_prefix.is_empty() && key.starts_with(&normalized_prefix) { + &key[normalized_prefix.len()..] + } else { + &key + }; + if relative.is_empty() { + continue; + } + if let Some(slash_pos) = relative.find('/') { + let folder_name = &relative[..slash_pos]; + if !folder_name.is_empty() { + folders.insert(folder_name.to_string()); + } + } else { + files.push((relative.to_string(), key.clone())); + } + } + let mut folder_vec: Vec = folders.into_iter().collect(); + folder_vec.sort(); + for folder_name in folder_vec { + let full_path = if normalized_prefix.is_empty() { + folder_name.clone() + } else { + format!("{}{}", normalized_prefix, folder_name) + }; + let display = format!("📁 {}/", folder_name); + self.items.push(( + display, + TreeNode::Folder { + bucket: bucket.to_string(), + path: full_path, + }, + )); + } + files.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (name, full_path) in files { + let icon = if name.ends_with(".bas") { + "⚙️" + } else if name.ends_with(".ast") { + "🔧" + } else if name.ends_with(".csv") { + "📊" + } else if name.ends_with(".gbkb") { + "📚" + } else if name.ends_with(".json") { + "🔖" + } else { + "📄" + }; + let display = format!("{} {}", icon, name); + self.items.push(( + display, + TreeNode::File { + bucket: bucket.to_string(), + path: full_path, + }, + )); + } + } + if self.items.len() == 1 { + self.items.push(( + "(empty folder)".to_string(), + TreeNode::Folder { + bucket: bucket.to_string(), + path: String::new(), + }, + )); + } + self.selected = 0; + Ok(()) + } + pub fn render_items(&self) -> &[(String, TreeNode)] { + &self.items + } + pub fn selected_index(&self) -> usize { + self.selected + } + pub fn get_selected_node(&self) -> Option<&TreeNode> { + self.items.get(self.selected).map(|(_, node)| node) + } + pub fn get_selected_bot(&self) -> Option { + if let Some(bucket) = &self.current_bucket { + if bucket.ends_with(".gbai") { + return Some(bucket.trim_end_matches(".gbai").to_string()); + } + } + if let Some((_, node)) = self.items.get(self.selected) { + match node { + TreeNode::Bucket { name } => { + if name.ends_with(".gbai") { + return Some(name.trim_end_matches(".gbai").to_string()); + } + } + _ => {} + } + } + None + } + pub fn move_up(&mut self) { + if self.selected > 0 { + self.selected -= 1; + } + } + pub fn move_down(&mut self) { + if self.selected < self.items.len().saturating_sub(1) { + self.selected += 1; + } + } } diff --git a/web/desktop/drive/drive.css b/web/desktop/drive/drive.css index a6cb5602f..27a094e82 100644 --- a/web/desktop/drive/drive.css +++ b/web/desktop/drive/drive.css @@ -1,238 +1,706 @@ -/* Drive Layout */ -.drive-layout { - display: grid; - grid-template-columns: 250px 1fr 300px; - gap: 1rem; - padding: 1rem; - height: 100%; +/* General Bots Drive - Theme-Integrated Styles */ + +/* ============================================ */ +/* DRIVE CONTAINER */ +/* ============================================ */ +.drive-container { + display: flex; + flex-direction: column; + height: 100vh; width: 100%; - background: #ffffff; - color: #202124; -} - -[data-theme="dark"] .drive-layout { - background: #1a1a1a; - color: #e8eaed; -} - -.drive-sidebar, -.drive-main, -.drive-details { - background: #f8f9fa; - border: 1px solid #e0e0e0; - border-radius: 12px; + background: var(--primary-bg); + color: var(--text-primary); + padding-top: var(--header-height); overflow: hidden; } -[data-theme="dark"] .drive-sidebar, -[data-theme="dark"] .drive-main, -[data-theme="dark"] .drive-details { - background: #202124; - border-color: #3c4043; +/* ============================================ */ +/* DRIVE HEADER */ +/* ============================================ */ +.drive-header { + background: var(--glass-bg); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border-color); + padding: var(--space-lg) var(--space-xl); + box-shadow: var(--shadow-sm); } +.header-content { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-md); +} + +.drive-title { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.drive-icon { + font-size: 1.75rem; +} + +.header-actions { + display: flex; + gap: var(--space-sm); +} + +.header-actions button { + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.875rem; +} + +.header-actions button svg { + width: 18px; + height: 18px; +} + +/* Search Bar */ +.search-bar { + position: relative; + max-width: 600px; +} + +.search-bar svg { + position: absolute; + left: var(--space-md); + top: 50%; + transform: translateY(-50%); + color: var(--text-secondary); + pointer-events: none; +} + +.search-input { + width: 100%; + padding: var(--space-sm) var(--space-md) var(--space-sm) 48px; + background: var(--input-bg); + border: 1px solid var(--input-border); + border-radius: var(--radius-lg); + color: var(--text-primary); + font-size: 0.875rem; + transition: all var(--transition-fast); +} + +.search-input::placeholder { + color: var(--input-placeholder); +} + +.search-input:focus { + outline: none; + border-color: var(--input-focus-border); + box-shadow: 0 0 0 3px var(--accent-light); +} + +/* ============================================ */ +/* DRIVE LAYOUT */ +/* ============================================ */ +.drive-layout { + display: grid; + grid-template-columns: 240px 1fr 320px; + gap: 0; + flex: 1; + overflow: hidden; +} + +/* ============================================ */ +/* SIDEBAR */ +/* ============================================ */ .drive-sidebar { + background: var(--secondary-bg); + border-right: 1px solid var(--border-color); + overflow-y: auto; + padding: var(--space-lg) 0; +} + +.sidebar-section { + margin-bottom: var(--space-xl); + padding: 0 var(--space-md); +} + +.sidebar-heading { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-tertiary); + margin: 0 0 var(--space-sm) var(--space-sm); +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + margin-bottom: var(--space-xs); + border-radius: var(--radius-md); + border: 1px solid transparent; + background: transparent; + color: var(--text-secondary); + font-size: 0.875rem; + cursor: pointer; + transition: all var(--transition-fast); + width: 100%; + text-align: left; +} + +.nav-item:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: var(--border-light); +} + +.nav-item.active { + background: var(--accent-light); + color: var(--accent-color); + border-color: var(--accent-color); + font-weight: 500; +} + +.nav-icon { + font-size: 1.25rem; + flex-shrink: 0; +} + +.nav-label { + flex: 1; +} + +.nav-badge { + background: var(--accent-color); + color: hsl(var(--primary-foreground)); + padding: 2px 8px; + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 600; + min-width: 20px; + text-align: center; +} + +/* Storage Info */ +.storage-info { + padding: var(--space-md); + background: var(--glass-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); +} + +.storage-bar { + height: 8px; + background: var(--muted); + border-radius: var(--radius-full); + overflow: hidden; + margin-bottom: var(--space-sm); +} + +.storage-used { + height: 100%; + background: var(--accent-gradient); + border-radius: var(--radius-full); + transition: width var(--transition-smooth); +} + +.storage-text { + font-size: 0.75rem; + color: var(--text-secondary); + margin: 0; +} + +/* ============================================ */ +/* MAIN CONTENT */ +/* ============================================ */ +.drive-main { + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--primary-bg); +} + +/* Breadcrumb */ +.breadcrumb { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-md) var(--space-xl); + border-bottom: 1px solid var(--border-color); + background: var(--secondary-bg); + flex-wrap: wrap; +} + +.breadcrumb-item { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.breadcrumb-item button { + background: none; + border: none; + color: var(--text-secondary); + font-size: 0.875rem; + cursor: pointer; + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.breadcrumb-item button:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.breadcrumb-item:last-child button { + color: var(--text-primary); + font-weight: 500; +} + +.breadcrumb-separator { + color: var(--text-tertiary); + user-select: none; +} + +/* View Controls */ +.view-controls { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-md) var(--space-xl); + background: var(--secondary-bg); + border-bottom: 1px solid var(--border-color); +} + +.view-toggle { + display: flex; + gap: var(--space-xs); + background: var(--primary-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 2px; +} + +.view-button { + padding: var(--space-xs) var(--space-sm); + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; +} + +.view-button:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.view-button.active { + background: var(--accent-color); + color: hsl(var(--primary-foreground)); +} + +.sort-select { + padding: var(--space-xs) var(--space-md); + background: var(--input-bg); + border: 1px solid var(--input-border); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 0.875rem; + cursor: pointer; + transition: all var(--transition-fast); +} + +.sort-select:focus { + outline: none; + border-color: var(--input-focus-border); + box-shadow: 0 0 0 2px var(--accent-light); +} + +/* ============================================ */ +/* FILE TREE VIEW */ +/* ============================================ */ +.file-tree { + flex: 1; + overflow-y: auto; + padding: var(--space-sm); +} + +.tree-item { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + margin-bottom: 2px; + border-radius: var(--radius-md); + border: 1px solid transparent; + cursor: pointer; + transition: all var(--transition-fast); + position: relative; +} + +.tree-item:hover { + background: var(--bg-hover); + border-color: var(--border-light); +} + +.tree-item:hover .tree-actions { + opacity: 1; + visibility: visible; +} + +.tree-item.selected { + background: var(--accent-light); + border-color: var(--accent-color); +} + +.tree-item.folder { + font-weight: 500; +} + +.tree-toggle { + width: 20px; + height: 20px; + padding: 0; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); + flex-shrink: 0; +} + +.tree-toggle:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.tree-icon { + font-size: 1.25rem; + flex-shrink: 0; +} + +.tree-label { + flex: 1; + font-size: 0.875rem; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tree-meta { + display: flex; + align-items: center; + gap: var(--space-md); + font-size: 0.75rem; + color: var(--text-secondary); + margin-left: auto; +} + +.tree-size { + min-width: 60px; + text-align: right; +} + +.tree-date { + min-width: 100px; + text-align: right; +} + +.tree-actions { + display: flex; + gap: var(--space-xs); + opacity: 0; + visibility: hidden; + transition: all var(--transition-fast); +} + +.action-button { + width: 28px; + height: 28px; + padding: 0; + background: var(--secondary-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); +} + +.action-button:hover { + background: var(--bg-hover); + border-color: var(--accent-color); + color: var(--accent-color); +} + +.action-button.danger:hover { + background: var(--error-color); + border-color: var(--error-color); + color: white; +} + +/* ============================================ */ +/* GRID VIEW */ +/* ============================================ */ +.file-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--space-md); + padding: var(--space-xl); overflow-y: auto; } -.drive-main { +.grid-item { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-lg); + background: hsl(var(--card)); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + cursor: pointer; + transition: all var(--transition-fast); +} + +.grid-item:hover { + background: var(--bg-hover); + border-color: var(--accent-color); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.grid-item.selected { + background: var(--accent-light); + border-color: var(--accent-color); + box-shadow: var(--shadow-md); +} + +.grid-icon { + font-size: 3rem; + margin-bottom: var(--space-md); +} + +.grid-name { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); + text-align: center; + word-break: break-word; + margin-bottom: var(--space-xs); +} + +.grid-meta { + font-size: 0.75rem; + color: var(--text-secondary); + text-align: center; + display: flex; + flex-direction: column; + gap: 2px; +} + +/* ============================================ */ +/* DETAILS PANEL */ +/* ============================================ */ +.drive-details { + background: var(--secondary-bg); + border-left: 1px solid var(--border-color); + overflow-y: auto; display: flex; flex-direction: column; } -.drive-details { - overflow-y: auto; -} - -/* Navigation Items */ -.nav-item { - padding: 0.75rem 1rem; +.details-header { display: flex; align-items: center; - gap: 0.75rem; - cursor: pointer; - border-radius: 0.5rem; - margin: 0.25rem 0.5rem; - transition: all 0.2s; - color: #5f6368; + justify-content: space-between; + padding: var(--space-lg); + border-bottom: 1px solid var(--border-color); } -[data-theme="dark"] .nav-item { - color: #9aa0a6; -} - -.nav-item:hover { - background: rgba(26, 115, 232, 0.08); - color: #1a73e8; -} - -[data-theme="dark"] .nav-item:hover { - background: rgba(138, 180, 248, 0.08); - color: #8ab4f8; -} - -.nav-item.active { - background: #e8f0fe; - color: #1a73e8; - font-weight: 500; -} - -[data-theme="dark"] .nav-item.active { - background: #1e3a5f; - color: #8ab4f8; -} - -/* File List */ -.file-list { - flex: 1; - overflow-y: auto; - padding: 0.5rem; -} - -.file-item { - padding: 0.75rem 1rem; - display: flex; - align-items: center; - gap: 1rem; - cursor: pointer; - border-radius: 0.5rem; - border: 1px solid transparent; - transition: all 0.2s; - margin-bottom: 0.25rem; -} - -.file-item:hover { - background: rgba(26, 115, 232, 0.08); - border-color: rgba(26, 115, 232, 0.2); -} - -[data-theme="dark"] .file-item:hover { - background: rgba(138, 180, 248, 0.08); - border-color: rgba(138, 180, 248, 0.2); -} - -.file-item.selected { - background: #e8f0fe; - border-color: #1a73e8; -} - -[data-theme="dark"] .file-item.selected { - background: #1e3a5f; - border-color: #8ab4f8; -} - -.file-icon { - font-size: 1.5rem; - flex-shrink: 0; -} - -/* Headers */ -h2, -h3 { +.details-header h3 { margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.close-button { + width: 32px; + height: 32px; padding: 0; - font-weight: 500; + background: transparent; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); } -/* Text Styles */ -.text-xs { - font-size: 0.75rem; +.close-button:hover { + background: var(--bg-hover); + border-color: var(--accent-color); + color: var(--text-primary); } -.text-sm { +.details-content { + padding: var(--space-lg); + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +.details-preview { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-xl); + background: var(--glass-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); +} + +.preview-icon { + font-size: 4rem; +} + +.details-info h4 { + margin: 0 0 var(--space-md) 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + word-break: break-word; +} + +.info-row { + display: flex; + justify-content: space-between; + padding: var(--space-sm) 0; + border-bottom: 1px solid var(--border-color); font-size: 0.875rem; } -.text-gray { - color: #5f6368; +.info-row:last-child { + border-bottom: none; } -[data-theme="dark"] .text-gray { - color: #9aa0a6; +.info-label { + color: var(--text-secondary); + font-weight: 500; } -/* Inputs */ -input[type="text"] { - font-family: inherit; +.info-value { + color: var(--text-primary); } -input[type="text"]:focus { - outline: none; - border-color: #1a73e8 !important; - box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2); +.details-actions { + display: flex; + flex-direction: column; + gap: var(--space-sm); + margin-top: auto; } -[data-theme="dark"] input[type="text"]:focus { - border-color: #8ab4f8 !important; - box-shadow: 0 0 0 2px rgba(138, 180, 248, 0.2); +.details-actions button { + width: 100%; + justify-content: center; } -/* Buttons */ -button { - font-family: inherit; - cursor: pointer; - transition: all 0.2s; +/* ============================================ */ +/* EMPTY STATE */ +/* ============================================ */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-2xl); + text-align: center; + color: var(--text-secondary); + flex: 1; } -button:hover { - opacity: 0.9; +.empty-state svg { + margin-bottom: var(--space-lg); + color: var(--text-tertiary); } -button:active { - transform: scale(0.98); +.empty-state h3 { + margin: 0 0 var(--space-sm) 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); } -/* Scrollbar Styles */ +.empty-state p { + margin: 0; + font-size: 0.875rem; + color: var(--text-secondary); +} + +/* ============================================ */ +/* SCROLLBAR */ +/* ============================================ */ .drive-sidebar::-webkit-scrollbar, -.file-list::-webkit-scrollbar, +.file-tree::-webkit-scrollbar, +.file-grid::-webkit-scrollbar, .drive-details::-webkit-scrollbar { width: 8px; + height: 8px; } .drive-sidebar::-webkit-scrollbar-track, -.file-list::-webkit-scrollbar-track, +.file-tree::-webkit-scrollbar-track, +.file-grid::-webkit-scrollbar-track, .drive-details::-webkit-scrollbar-track { - background: transparent; + background: var(--scrollbar-track); } .drive-sidebar::-webkit-scrollbar-thumb, -.file-list::-webkit-scrollbar-thumb, +.file-tree::-webkit-scrollbar-thumb, +.file-grid::-webkit-scrollbar-thumb, .drive-details::-webkit-scrollbar-thumb { - background: rgba(128, 128, 128, 0.3); - border-radius: 4px; + background: var(--scrollbar-thumb); + border-radius: var(--radius-full); } .drive-sidebar::-webkit-scrollbar-thumb:hover, -.file-list::-webkit-scrollbar-thumb:hover, +.file-tree::-webkit-scrollbar-thumb:hover, +.file-grid::-webkit-scrollbar-thumb:hover, .drive-details::-webkit-scrollbar-thumb:hover { - background: rgba(128, 128, 128, 0.5); + background: var(--scrollbar-thumb-hover); } -[data-theme="dark"] .drive-sidebar::-webkit-scrollbar-thumb, -[data-theme="dark"] .file-list::-webkit-scrollbar-thumb, -[data-theme="dark"] .drive-details::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); -} - -[data-theme="dark"] .drive-sidebar::-webkit-scrollbar-thumb:hover, -[data-theme="dark"] .file-list::-webkit-scrollbar-thumb:hover, -[data-theme="dark"] .drive-details::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.3); -} - -/* Responsive */ -@media (max-width: 1024px) { +/* ============================================ */ +/* RESPONSIVE DESIGN */ +/* ============================================ */ +@media (max-width: 1280px) { .drive-layout { - grid-template-columns: 200px 1fr 250px; - gap: 0.5rem; - padding: 0.5rem; + grid-template-columns: 200px 1fr 280px; } } -@media (max-width: 768px) { +@media (max-width: 1024px) { .drive-layout { - grid-template-columns: 1fr; - grid-template-rows: auto 1fr; + grid-template-columns: 180px 1fr; } .drive-details { @@ -240,7 +708,243 @@ button:active { } } -/* Alpine.js cloak */ +/* ============================================ */ +/* TEXT EDITOR MODAL */ +/* ============================================ */ +.editor-modal { + position: fixed; + inset: 0; + background: hsla(var(--foreground) / 0.8); + backdrop-filter: blur(10px); + display: flex; + align-items: center; + justify-content: center; + z-index: var(--z-modal); + padding: var(--space-xl); +} + +.editor-container { + width: 100%; + max-width: 1400px; + height: 90vh; + background: var(--primary-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.editor-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-lg) var(--space-xl); + background: var(--secondary-bg); + border-bottom: 1px solid var(--border-color); +} + +.editor-title { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.editor-title svg { + color: var(--accent-color); +} + +.editor-actions { + display: flex; + gap: var(--space-sm); +} + +.editor-actions button { + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.875rem; +} + +.editor-actions button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.editor-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--primary-bg); +} + +.editor-textarea { + flex: 1; + width: 100%; + padding: var(--space-xl); + background: var(--primary-bg); + border: none; + color: var(--text-primary); + font-family: "Consolas", "Monaco", "Courier New", monospace; + font-size: 14px; + line-height: 1.6; + resize: none; + outline: none; + tab-size: 4; +} + +.editor-textarea::placeholder { + color: var(--text-tertiary); +} + +.editor-loading { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-lg); + color: var(--text-secondary); +} + +.editor-loading .loading-spinner { + width: 48px; + height: 48px; + border: 4px solid var(--border-color); + border-top-color: var(--accent-color); + border-radius: var(--radius-full); + animation: spin 0.8s linear infinite; +} + +.editor-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-md) var(--space-xl); + background: var(--secondary-bg); + border-top: 1px solid var(--border-color); + font-size: 0.75rem; + color: var(--text-secondary); +} + +.editor-info { + display: flex; + gap: var(--space-sm); +} + +.editor-path { + font-family: "Consolas", "Monaco", "Courier New", monospace; + color: var(--text-tertiary); +} + +@media (max-width: 768px) { + .editor-modal { + padding: 0; + } + + .editor-container { + width: 100%; + height: 100vh; + max-width: 100%; + border-radius: 0; + } + + .editor-header, + .editor-footer { + padding: var(--space-md); + } + + .editor-textarea { + padding: var(--space-md); + font-size: 13px; + } +} + +/* ============================================ */ +/* RESPONSIVE DESIGN */ +/* ============================================ */ +@media (max-width: 768px) { + .drive-header { + padding: var(--space-md); + } + + .header-content { + flex-direction: column; + align-items: flex-start; + gap: var(--space-md); + } + + .header-actions { + width: 100%; + justify-content: stretch; + } + + .header-actions button { + flex: 1; + } + + .drive-layout { + grid-template-columns: 1fr; + } + + .drive-sidebar { + display: none; + } + + .file-grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: var(--space-sm); + padding: var(--space-md); + } + + .tree-meta { + display: none; + } + + .tree-item { + padding: var(--space-sm); + } +} + +@media (max-width: 480px) { + .breadcrumb { + padding: var(--space-sm) var(--space-md); + } + + .view-controls { + padding: var(--space-sm) var(--space-md); + } + + .file-grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + } +} + +/* ============================================ */ +/* ALPINE.JS CLOAK */ +/* ============================================ */ [x-cloak] { display: none !important; } + +/* ============================================ */ +/* PRINT STYLES */ +/* ============================================ */ +@media print { + .drive-header, + .drive-sidebar, + .drive-details, + .view-controls, + .tree-actions { + display: none !important; + } + + .drive-layout { + grid-template-columns: 1fr; + } +} diff --git a/web/desktop/drive/drive.html b/web/desktop/drive/drive.html index a77cf95c8..42e4c3397 100644 --- a/web/desktop/drive/drive.html +++ b/web/desktop/drive/drive.html @@ -1,125 +1,586 @@ -
-
-
-

General Bots Drive

-
- -
- -
-
-

+
+ -
- -
-
- - + + + +
diff --git a/web/desktop/drive/drive.js b/web/desktop/drive/drive.js index ee0658a59..170adbccc 100644 --- a/web/desktop/drive/drive.js +++ b/web/desktop/drive/drive.js @@ -1,85 +1,520 @@ window.driveApp = function driveApp() { return { - current: "All Files", - search: "", - selectedFile: null, - navItems: [ - { name: "All Files", icon: "📁" }, - { name: "Recent", icon: "🕐" }, - { name: "Starred", icon: "⭐" }, - { name: "Shared", icon: "👥" }, - { name: "Trash", icon: "🗑" }, + currentView: "all", + viewMode: "tree", + sortBy: "name", + searchQuery: "", + selectedItem: null, + currentPath: "/", + currentBucket: null, + showUploadDialog: false, + + showEditor: false, + editorContent: "", + editorFilePath: "", + editorFileName: "", + editorLoading: false, + editorSaving: false, + + quickAccess: [ + { id: "all", label: "All Files", icon: "📁", count: null }, + { id: "recent", label: "Recent", icon: "🕐", count: null }, + { id: "starred", label: "Starred", icon: "⭐", count: 3 }, + { id: "shared", label: "Shared", icon: "👥", count: 5 }, + { id: "trash", label: "Trash", icon: "🗑️", count: 0 }, ], - files: [ - { - id: 1, - name: "Project Proposal.pdf", - type: "PDF", - icon: "📄", - size: "2.4 MB", - date: "Nov 10, 2025", - }, - { - id: 2, - name: "Design Assets", - type: "Folder", - icon: "📁", - size: "—", - date: "Nov 12, 2025", - }, - { - id: 3, - name: "Meeting Notes.docx", - type: "Document", - icon: "📝", - size: "156 KB", - date: "Nov 14, 2025", - }, - { - id: 4, - name: "Budget 2025.xlsx", - type: "Spreadsheet", - icon: "📊", - size: "892 KB", - date: "Nov 13, 2025", - }, - { - id: 5, - name: "Presentation.pptx", - type: "Presentation", - icon: "📽", - size: "5.2 MB", - date: "Nov 11, 2025", - }, - { - id: 6, - name: "team-photo.jpg", - type: "Image", - icon: "🖼", - size: "3.1 MB", - date: "Nov 9, 2025", - }, - { - id: 7, - name: "source-code.zip", - type: "Archive", - icon: "📦", - size: "12.8 MB", - date: "Nov 8, 2025", - }, - { - id: 8, - name: "video-demo.mp4", - type: "Video", - icon: "🎬", - size: "45.2 MB", - date: "Nov 7, 2025", - }, - ], - get filteredFiles() { - return this.files.filter((file) => - file.name.toLowerCase().includes(this.search.toLowerCase()), + + storageUsed: "12.3 GB", + storageTotal: "50 GB", + storagePercent: 25, + + fileTree: [], + loading: false, + error: null, + + get allItems() { + const flatten = (items) => { + let result = []; + items.forEach((item) => { + result.push(item); + if (item.children && item.expanded) { + result = result.concat(flatten(item.children)); + } + }); + return result; + }; + return flatten(this.fileTree); + }, + + get filteredItems() { + let items = this.allItems; + + if (this.searchQuery.trim()) { + const query = this.searchQuery.toLowerCase(); + items = items.filter((item) => item.name.toLowerCase().includes(query)); + } + + items = [...items].sort((a, b) => { + if (a.type === "folder" && b.type !== "folder") return -1; + if (a.type !== "folder" && b.type === "folder") return 1; + + switch (this.sortBy) { + case "name": + return a.name.localeCompare(b.name); + case "modified": + return new Date(b.modified) - new Date(a.modified); + case "size": + return ( + this.sizeToBytes(b.size || "0") - this.sizeToBytes(a.size || "0") + ); + case "type": + return (a.type || "").localeCompare(b.type || ""); + default: + return 0; + } + }); + + return items; + }, + + get breadcrumbs() { + const crumbs = [{ name: "Home", path: "/" }]; + + if (this.currentBucket) { + crumbs.push({ + name: this.currentBucket, + path: `/${this.currentBucket}`, + }); + + if (this.currentPath && this.currentPath !== "/") { + const parts = this.currentPath.split("/").filter(Boolean); + let currentPath = `/${this.currentBucket}`; + parts.forEach((part) => { + currentPath += `/${part}`; + crumbs.push({ name: part, path: currentPath }); + }); + } + } + + return crumbs; + }, + + async loadFiles(bucket = null, path = null) { + this.loading = true; + this.error = null; + + try { + const params = new URLSearchParams(); + if (bucket) params.append("bucket", bucket); + if (path) params.append("path", path); + + const response = await fetch(`/files/list?${params.toString()}`); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const files = await response.json(); + this.fileTree = this.convertToTree(files, bucket, path); + this.currentBucket = bucket; + this.currentPath = path || "/"; + } catch (err) { + console.error("Error loading files:", err); + this.error = err.toString(); + this.fileTree = this.getMockData(); + } finally { + this.loading = false; + } + }, + + convertToTree(files, bucket, basePath) { + return files.map((file) => { + const depth = basePath ? basePath.split("/").filter(Boolean).length : 0; + + return { + id: file.path, + name: file.name, + type: file.is_dir ? "folder" : this.getFileTypeFromName(file.name), + path: file.path, + bucket: bucket, + depth: depth, + expanded: false, + modified: new Date().toISOString().split("T")[0], + created: new Date().toISOString().split("T")[0], + size: file.is_dir ? null : "0 KB", + children: file.is_dir ? [] : undefined, + isDir: file.is_dir, + icon: file.icon, + }; + }); + }, + + getFileTypeFromName(filename) { + const ext = filename.split(".").pop().toLowerCase(); + const typeMap = { + pdf: "pdf", + doc: "document", + docx: "document", + txt: "text", + md: "text", + bas: "code", + ast: "code", + xls: "spreadsheet", + xlsx: "spreadsheet", + csv: "spreadsheet", + ppt: "presentation", + pptx: "presentation", + jpg: "image", + jpeg: "image", + png: "image", + gif: "image", + svg: "image", + mp4: "video", + avi: "video", + mov: "video", + mp3: "audio", + wav: "audio", + zip: "archive", + rar: "archive", + tar: "archive", + gz: "archive", + js: "code", + ts: "code", + py: "code", + java: "code", + cpp: "code", + rs: "code", + go: "code", + html: "code", + css: "code", + json: "code", + xml: "code", + gbkb: "knowledge", + exe: "executable", + }; + return typeMap[ext] || "file"; + }, + + getMockData() { + return [ + { + id: 1, + name: "Documents", + type: "folder", + path: "/Documents", + depth: 0, + expanded: true, + modified: "2024-01-15", + created: "2024-01-01", + isDir: true, + icon: "📁", + children: [ + { + id: 2, + name: "notes.txt", + type: "text", + path: "/Documents/notes.txt", + depth: 1, + size: "4 KB", + modified: "2024-01-14", + created: "2024-01-13", + icon: "📃", + }, + ], + }, + ]; + }, + + getFileIcon(item) { + if (item.icon) return item.icon; + + const iconMap = { + folder: "📁", + pdf: "📄", + document: "📝", + text: "📃", + spreadsheet: "📊", + presentation: "📽️", + image: "🖼️", + video: "🎬", + audio: "🎵", + archive: "📦", + code: "💻", + knowledge: "📚", + executable: "⚙️", + }; + return iconMap[item.type] || "📄"; + }, + + async toggleFolder(item) { + if (item.type === "folder") { + item.expanded = !item.expanded; + + if (item.expanded && item.children.length === 0) { + try { + const params = new URLSearchParams(); + params.append("bucket", item.bucket || item.name); + if (item.path !== item.name) { + params.append("path", item.path); + } + + const response = await fetch(`/files/list?${params.toString()}`); + if (response.ok) { + const files = await response.json(); + item.children = this.convertToTree( + files, + item.bucket || item.name, + item.path, + ); + } + } catch (err) { + console.error("Error loading folder contents:", err); + } + } + } + }, + + openFolder(item) { + if (item.type === "folder") { + this.loadFiles(item.bucket || item.name, item.path); + } + }, + + selectItem(item) { + this.selectedItem = item; + }, + + navigateToPath(path) { + if (path === "/") { + this.loadFiles(null, null); + } else { + const parts = path.split("/").filter(Boolean); + const bucket = parts[0]; + const filePath = parts.slice(1).join("/"); + this.loadFiles(bucket, filePath || "/"); + } + }, + + isEditableFile(item) { + if (item.type === "folder") return false; + const editableTypes = ["text", "code"]; + const editableExtensions = [ + "txt", + "md", + "js", + "ts", + "json", + "html", + "css", + "xml", + "csv", + "log", + "yml", + "yaml", + "ini", + "conf", + "sh", + "bat", + "bas", + "ast", + "gbkb", + ]; + + if (editableTypes.includes(item.type)) return true; + + const ext = item.name.split(".").pop().toLowerCase(); + return editableExtensions.includes(ext); + }, + + async editFile(item) { + if (!this.isEditableFile(item)) { + alert(`Cannot edit ${item.type} files. Only text files can be edited.`); + return; + } + + this.editorLoading = true; + this.showEditor = true; + this.editorFileName = item.name; + this.editorFilePath = item.path; + + try { + const response = await fetch("/files/read", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + bucket: item.bucket || this.currentBucket, + path: item.path, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to read file"); + } + + const data = await response.json(); + this.editorContent = data.content; + } catch (err) { + console.error("Error reading file:", err); + alert(`Error opening file: ${err.message}`); + this.showEditor = false; + } finally { + this.editorLoading = false; + } + }, + + async saveFile() { + if (!this.editorFilePath) return; + + this.editorSaving = true; + + try { + const response = await fetch("/files/write", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + bucket: this.currentBucket, + path: this.editorFilePath, + content: this.editorContent, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save file"); + } + + alert("File saved successfully!"); + } catch (err) { + console.error("Error saving file:", err); + alert(`Error saving file: ${err.message}`); + } finally { + this.editorSaving = false; + } + }, + + closeEditor() { + if ( + this.editorContent && + confirm("Close editor? Unsaved changes will be lost.") + ) { + this.showEditor = false; + this.editorContent = ""; + this.editorFilePath = ""; + this.editorFileName = ""; + } else if (!this.editorContent) { + this.showEditor = false; + } + }, + + async downloadItem(item) { + window.open( + `/files/download?bucket=${item.bucket}&path=${item.path}`, + "_blank", ); }, + + shareItem(item) { + const shareUrl = `${window.location.origin}/files/share?bucket=${item.bucket}&path=${item.path}`; + prompt("Share link:", shareUrl); + }, + + async deleteItem(item) { + if (!confirm(`Are you sure you want to delete "${item.name}"?`)) return; + + try { + const response = await fetch("/files/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + bucket: item.bucket || this.currentBucket, + path: item.path, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to delete"); + } + + alert("Deleted successfully!"); + this.loadFiles(this.currentBucket, this.currentPath); + this.selectedItem = null; + } catch (err) { + console.error("Error deleting:", err); + alert(`Error: ${err.message}`); + } + }, + + async createFolder() { + const name = prompt("Enter folder name:"); + if (!name) return; + + try { + const response = await fetch("/files/create-folder", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + bucket: this.currentBucket, + path: this.currentPath === "/" ? "" : this.currentPath, + name: name, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to create folder"); + } + + alert("Folder created!"); + this.loadFiles(this.currentBucket, this.currentPath); + } catch (err) { + console.error("Error creating folder:", err); + alert(`Error: ${err.message}`); + } + }, + + sizeToBytes(sizeStr) { + if (!sizeStr || sizeStr === "—") return 0; + + const units = { + B: 1, + KB: 1024, + MB: 1024 * 1024, + GB: 1024 * 1024 * 1024, + TB: 1024 * 1024 * 1024 * 1024, + }; + + const match = sizeStr.match(/^([\d.]+)\s*([A-Z]+)$/i); + if (!match) return 0; + + const value = parseFloat(match[1]); + const unit = match[2].toUpperCase(); + + return value * (units[unit] || 1); + }, + + renderChildren(item) { + return ""; + }, + + init() { + console.log("✓ Drive component initialized"); + this.loadFiles(null, null); + + const section = document.querySelector("#section-drive"); + if (section) { + section.addEventListener("section-shown", () => { + console.log("Drive section shown"); + this.loadFiles(this.currentBucket, this.currentPath); + }); + + section.addEventListener("section-hidden", () => { + console.log("Drive section hidden"); + }); + } + }, }; }; + +console.log("✓ Drive app function registered"); diff --git a/web/desktop/mail/mail.js b/web/desktop/mail/mail.js index 58d385925..e99089f73 100644 --- a/web/desktop/mail/mail.js +++ b/web/desktop/mail/mail.js @@ -2,71 +2,50 @@ window.mailApp = function mailApp() { return { currentFolder: "Inbox", selectedMail: null, + composing: false, + loading: false, + sending: false, + currentAccountId: null, folders: [ - { name: "Inbox", icon: "📥", count: 4 }, + { name: "Inbox", icon: "📥", count: 0 }, { name: "Sent", icon: "📤", count: 0 }, - { name: "Drafts", icon: "📝", count: 2 }, + { name: "Drafts", icon: "📝", count: 0 }, { name: "Starred", icon: "⭐", count: 0 }, { name: "Trash", icon: "🗑", count: 0 }, ], - mails: [ - { - id: 1, - from: "Sarah Johnson", - to: "me@example.com", - subject: "Q4 Project Update", - preview: - "Hi team, I wanted to share the latest updates on our Q4 projects...", - body: "

Hi team,

I wanted to share the latest updates on our Q4 projects. We've made significant progress on the main deliverables and are on track to meet our goals.

Please review the attached documents and let me know if you have any questions.

Best regards,
Sarah

", - time: "10:30 AM", - date: "Nov 15, 2025", - read: false, - }, - { - id: 2, - from: "Mike Chen", - to: "me@example.com", - subject: "Meeting Tomorrow", - preview: "Don't forget about our meeting tomorrow at 2 PM...", - body: "

Hi,

Don't forget about our meeting tomorrow at 2 PM to discuss the new features.

See you then!
Mike

", - time: "9:15 AM", - date: "Nov 15, 2025", - read: false, - }, - { - id: 3, - from: "Emma Wilson", - to: "me@example.com", - subject: "Design Review Complete", - preview: "The design review for the new dashboard is complete...", - body: "

Hi,

The design review for the new dashboard is complete. Overall, the team is happy with the direction.

I've made the requested changes and updated the Figma file.

Thanks,
Emma

", - time: "Yesterday", - date: "Nov 14, 2025", - read: true, - }, - { - id: 4, - from: "David Lee", - to: "me@example.com", - subject: "Budget Approval Needed", - preview: "Could you please review and approve the Q1 budget?", - body: "

Hi,

Could you please review and approve the Q1 budget when you get a chance?

It's attached to this email.

Thanks,
David

", - time: "Yesterday", - date: "Nov 14, 2025", - read: false, - }, - ], + mails: [], + + // Compose form + composeForm: { + to: "", + cc: "", + bcc: "", + subject: "", + body: "", + }, + + // User accounts + emailAccounts: [], get filteredMails() { - return this.mails; + // Filter by folder + let filtered = this.mails; + + // TODO: Implement folder filtering based on IMAP folders + // For now, show all in Inbox + + return filtered; }, selectMail(mail) { this.selectedMail = mail; mail.read = true; this.updateFolderCounts(); + + // TODO: Mark as read on server + this.markEmailAsRead(mail.id); }, updateFolderCounts() { @@ -75,5 +54,403 @@ window.mailApp = function mailApp() { inbox.count = this.mails.filter((m) => !m.read).length; } }, + + async init() { + console.log("✓ Mail component initialized"); + + // Load email accounts first + await this.loadEmailAccounts(); + + // If we have accounts, load emails for the first/primary account + if (this.emailAccounts.length > 0) { + const primaryAccount = + this.emailAccounts.find((a) => a.is_primary) || this.emailAccounts[0]; + this.currentAccountId = primaryAccount.id; + await this.loadEmails(); + } + + // Listen for account updates + window.addEventListener("email-accounts-updated", () => { + this.loadEmailAccounts(); + }); + + // Listen for section visibility + const section = document.querySelector("#section-mail"); + if (section) { + section.addEventListener("section-shown", () => { + console.log("Mail section shown"); + if (this.currentAccountId) { + this.loadEmails(); + } + }); + + section.addEventListener("section-hidden", () => { + console.log("Mail section hidden"); + }); + } + }, + + async loadEmailAccounts() { + try { + const response = await fetch("/api/email/accounts"); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + if (result.success && result.data) { + this.emailAccounts = result.data; + console.log(`Loaded ${this.emailAccounts.length} email accounts`); + + // If no current account is selected, select the first/primary one + if (!this.currentAccountId && this.emailAccounts.length > 0) { + const primaryAccount = + this.emailAccounts.find((a) => a.is_primary) || + this.emailAccounts[0]; + this.currentAccountId = primaryAccount.id; + await this.loadEmails(); + } + } else { + this.emailAccounts = []; + console.warn("No email accounts configured"); + } + } catch (error) { + console.error("Error loading email accounts:", error); + this.emailAccounts = []; + } + }, + + async loadEmails() { + if (!this.currentAccountId) { + console.warn("No email account selected"); + this.showNotification( + "Please configure an email account first", + "warning", + ); + return; + } + + this.loading = true; + try { + const response = await fetch("/api/email/list", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + account_id: this.currentAccountId, + folder: this.currentFolder.toUpperCase(), + limit: 50, + offset: 0, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + + if (result.success && result.data) { + this.mails = result.data.map((email) => ({ + id: email.id, + from: email.from_name || email.from_email, + to: email.to, + subject: email.subject, + preview: email.preview, + body: email.body, + time: email.time, + date: email.date, + read: email.read, + has_attachments: email.has_attachments, + folder: email.folder, + })); + + this.updateFolderCounts(); + console.log( + `Loaded ${this.mails.length} emails from ${this.currentFolder}`, + ); + } else { + console.warn("Failed to load emails:", result.message); + this.mails = []; + } + } catch (error) { + console.error("Error loading emails:", error); + this.showNotification( + "Failed to load emails: " + error.message, + "error", + ); + this.mails = []; + } finally { + this.loading = false; + } + }, + + async switchAccount(accountId) { + this.currentAccountId = accountId; + this.selectedMail = null; + await this.loadEmails(); + }, + + async switchFolder(folderName) { + this.currentFolder = folderName; + this.selectedMail = null; + await this.loadEmails(); + }, + + async markEmailAsRead(emailId) { + if (!this.currentAccountId) return; + + try { + await fetch("/api/email/mark", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + account_id: this.currentAccountId, + email_id: emailId, + read: true, + }), + }); + } catch (error) { + console.error("Error marking email as read:", error); + } + }, + + async deleteEmail(emailId) { + if (!this.currentAccountId) return; + + if (!confirm("Are you sure you want to delete this email?")) { + return; + } + + try { + const response = await fetch("/api/email/delete", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + account_id: this.currentAccountId, + email_id: emailId, + }), + }); + + const result = await response.json(); + + if (result.success) { + this.showNotification("Email deleted", "success"); + this.selectedMail = null; + await this.loadEmails(); + } else { + throw new Error(result.message || "Failed to delete email"); + } + } catch (error) { + console.error("Error deleting email:", error); + this.showNotification( + "Failed to delete email: " + error.message, + "error", + ); + } + }, + + startCompose() { + this.composing = true; + this.composeForm = { + to: "", + cc: "", + bcc: "", + subject: "", + body: "", + }; + }, + + startReply() { + if (!this.selectedMail) return; + + this.composing = true; + this.composeForm = { + to: this.selectedMail.from, + cc: "", + bcc: "", + subject: "Re: " + this.selectedMail.subject, + body: + "\n\n---\nOn " + + this.selectedMail.date + + ", " + + this.selectedMail.from + + " wrote:\n" + + this.selectedMail.body, + }; + }, + + startForward() { + if (!this.selectedMail) return; + + this.composing = true; + this.composeForm = { + to: "", + cc: "", + bcc: "", + subject: "Fwd: " + this.selectedMail.subject, + body: + "\n\n---\nForwarded message:\nFrom: " + + this.selectedMail.from + + "\nSubject: " + + this.selectedMail.subject + + "\n\n" + + this.selectedMail.body, + }; + }, + + cancelCompose() { + if ( + this.composeForm.to || + this.composeForm.subject || + this.composeForm.body + ) { + if (!confirm("Discard draft?")) { + return; + } + } + this.composing = false; + }, + + async sendEmail() { + if (!this.currentAccountId) { + this.showNotification("Please select an email account", "error"); + return; + } + + if (!this.composeForm.to) { + this.showNotification("Please enter a recipient", "error"); + return; + } + + if (!this.composeForm.subject) { + this.showNotification("Please enter a subject", "error"); + return; + } + + this.sending = true; + try { + const response = await fetch("/api/email/send", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + account_id: this.currentAccountId, + to: this.composeForm.to, + cc: this.composeForm.cc || null, + bcc: this.composeForm.bcc || null, + subject: this.composeForm.subject, + body: this.composeForm.body, + is_html: false, + }), + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.message || "Failed to send email"); + } + + this.showNotification("Email sent successfully", "success"); + this.composing = false; + this.composeForm = { + to: "", + cc: "", + bcc: "", + subject: "", + body: "", + }; + + // Reload emails to show sent message in Sent folder + await this.loadEmails(); + } catch (error) { + console.error("Error sending email:", error); + this.showNotification( + "Failed to send email: " + error.message, + "error", + ); + } finally { + this.sending = false; + } + }, + + async saveDraft() { + if (!this.currentAccountId) { + this.showNotification("Please select an email account", "error"); + return; + } + + try { + const response = await fetch("/api/email/draft", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + account_id: this.currentAccountId, + to: this.composeForm.to, + cc: this.composeForm.cc || null, + bcc: this.composeForm.bcc || null, + subject: this.composeForm.subject, + body: this.composeForm.body, + }), + }); + + const result = await response.json(); + + if (result.success) { + this.showNotification("Draft saved", "success"); + } else { + throw new Error(result.message || "Failed to save draft"); + } + } catch (error) { + console.error("Error saving draft:", error); + this.showNotification( + "Failed to save draft: " + error.message, + "error", + ); + } + }, + + async refreshEmails() { + await this.loadEmails(); + }, + + openAccountSettings() { + // Trigger navigation to account settings + if (window.showSection) { + window.showSection("account"); + } else { + this.showNotification( + "Please configure email accounts in Settings", + "info", + ); + } + }, + + getCurrentAccountName() { + if (!this.currentAccountId) return "No account"; + const account = this.emailAccounts.find( + (a) => a.id === this.currentAccountId, + ); + return account ? account.display_name || account.email : "Unknown"; + }, + + showNotification(message, type = "info") { + // Try to use the global notification system if available + if (window.showNotification) { + window.showNotification(message, type); + } else { + console.log(`[${type.toUpperCase()}] ${message}`); + } + }, }; }; + +console.log("✓ Mail app function registered"); diff --git a/web/desktop/tasks/tasks.css b/web/desktop/tasks/tasks.css index 5f2d8c43c..002703e2b 100644 --- a/web/desktop/tasks/tasks.css +++ b/web/desktop/tasks/tasks.css @@ -1,288 +1,673 @@ -/* Tasks Container */ +/* General Bots Tasks - Theme-Integrated Styles */ + +/* ============================================ */ +/* TASKS CONTAINER */ +/* ============================================ */ .tasks-container { - max-width: 800px; - margin: 0 auto; - padding: 2rem; - height: 100%; - overflow-y: auto; - background: #ffffff; - color: #202124; -} - -[data-theme="dark"] .tasks-container { - background: #1a1a1a; - color: #e8eaed; -} - -/* Task Input */ -.task-input { display: flex; - gap: 0.5rem; - margin-bottom: 2rem; + flex-direction: column; + height: 100vh; + width: 100%; + background: var(--primary-bg); + color: var(--text-primary); + padding-top: var(--header-height); + overflow: hidden; } -.task-input input { - flex: 1; - padding: 0.875rem 1rem; - background: #f8f9fa; - border: 1px solid #e0e0e0; - border-radius: 8px; - color: #202124; - font-size: 1rem; - font-family: inherit; - transition: all 0.2s; +/* ============================================ */ +/* TASKS HEADER */ +/* ============================================ */ +.tasks-header { + background: var(--glass-bg); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border-color); + padding: var(--space-lg) var(--space-xl); + box-shadow: var(--shadow-sm); } -[data-theme="dark"] .task-input input { - background: #202124; - border-color: #3c4043; - color: #e8eaed; +.header-content { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 1200px; + margin: 0 auto; } -.task-input input:focus { - outline: none; - border-color: #1a73e8; - box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2); -} - -[data-theme="dark"] .task-input input:focus { - border-color: #8ab4f8; - box-shadow: 0 0 0 2px rgba(138, 180, 248, 0.2); -} - -.task-input input::placeholder { - color: #5f6368; -} - -[data-theme="dark"] .task-input input::placeholder { - color: #9aa0a6; -} - -.task-input button { - padding: 0.875rem 1.5rem; - background: #1a73e8; - color: white; - border: none; - border-radius: 8px; - cursor: pointer; - font-weight: 500; - font-size: 1rem; - transition: all 0.2s; - white-space: nowrap; -} - -.task-input button:hover { - background: #1557b0; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.task-input button:active { - transform: scale(0.98); -} - -/* Task List */ -.task-list { - list-style: none; - padding: 0; +.tasks-title { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); margin: 0; } -.task-item { - padding: 1rem; +.tasks-icon { + font-size: 1.75rem; display: flex; align-items: center; - gap: 1rem; - background: #f8f9fa; - border: 1px solid #e0e0e0; - border-radius: 8px; - margin-bottom: 0.5rem; - transition: all 0.2s; + justify-content: center; + width: 48px; + height: 48px; + background: var(--accent-gradient); + border-radius: var(--radius-lg); + color: white; } -[data-theme="dark"] .task-item { - background: #202124; - border-color: #3c4043; +.header-stats { + display: flex; + gap: var(--space-xl); +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-xs); +} + +.stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--accent-color); +} + +.stat-label { + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} + +/* ============================================ */ +/* TASK INPUT SECTION */ +/* ============================================ */ +.task-input-section { + background: var(--secondary-bg); + border-bottom: 1px solid var(--border-color); + padding: var(--space-lg) var(--space-xl); +} + +.input-wrapper { + max-width: 1200px; + margin: 0 auto; + display: flex; + gap: var(--space-sm); + align-items: center; + position: relative; +} + +.input-icon { + position: absolute; + left: var(--space-md); + color: var(--text-secondary); + pointer-events: none; +} + +.task-input { + flex: 1; + padding: var(--space-md) var(--space-md) var(--space-md) 48px; + background: var(--input-bg); + border: 2px solid var(--input-border); + border-radius: var(--radius-lg); + color: var(--text-primary); + font-size: 1rem; + transition: all var(--transition-fast); +} + +.task-input::placeholder { + color: var(--input-placeholder); +} + +.task-input:focus { + outline: none; + border-color: var(--input-focus-border); + box-shadow: 0 0 0 3px var(--accent-light); +} + +.add-task-btn { + display: flex; + align-items: center; + gap: var(--space-xs); + white-space: nowrap; +} + +.add-task-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ============================================ */ +/* FILTER TABS */ +/* ============================================ */ +.filter-tabs { + display: flex; + gap: var(--space-xs); + padding: var(--space-md) var(--space-xl); + background: var(--secondary-bg); + border-bottom: 1px solid var(--border-color); + overflow-x: auto; +} + +.filter-tab { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-lg); + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-lg); + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; +} + +.filter-tab:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: var(--border-color); +} + +.filter-tab.active { + background: var(--accent-color); + color: hsl(var(--primary-foreground)); + border-color: var(--accent-color); +} + +.filter-tab svg { + flex-shrink: 0; +} + +.tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + background: hsla(var(--foreground) / 0.1); + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 600; +} + +.filter-tab.active .tab-badge { + background: hsla(var(--primary-foreground) / 0.2); + color: hsl(var(--primary-foreground)); +} + +/* ============================================ */ +/* TASKS MAIN */ +/* ============================================ */ +.tasks-main { + flex: 1; + overflow-y: auto; + padding: var(--space-xl); +} + +.task-list { + max-width: 1200px; + margin: 0 auto; +} + +/* ============================================ */ +/* TASK ITEM */ +/* ============================================ */ +.task-item { + display: flex; + align-items: flex-start; + gap: var(--space-md); + padding: var(--space-lg); + margin-bottom: var(--space-sm); + background: hsl(var(--card)); + border: 2px solid var(--border-color); + border-radius: var(--radius-lg); + transition: all var(--transition-fast); + position: relative; } .task-item:hover { - border-color: #1a73e8; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + border-color: var(--accent-color); + box-shadow: var(--shadow-md); + transform: translateY(-2px); } -[data-theme="dark"] .task-item:hover { - border-color: #8ab4f8; +.task-item:hover .task-actions { + opacity: 1; + visibility: visible; } .task-item.completed { opacity: 0.6; + background: var(--muted); } -.task-item.completed span { - text-decoration: line-through; +.task-item.priority { + border-color: var(--warning-color); + background: hsla(var(--chart-3) / 0.05); } -.task-item input[type="checkbox"] { - width: 1.25rem; - height: 1.25rem; - cursor: pointer; - accent-color: #1a73e8; +.task-item.priority::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: var(--warning-color); + border-radius: var(--radius-lg) 0 0 var(--radius-lg); +} + +.task-item.editing { + border-color: var(--accent-color); + box-shadow: 0 0 0 3px var(--accent-light); +} + +/* Checkbox */ +.task-checkbox-wrapper { + position: relative; flex-shrink: 0; + padding-top: 2px; } -.task-item span { - flex: 1; - font-size: 1rem; - line-height: 1.5; -} - -.task-item button { - background: #ea4335; - color: white; - border: none; - padding: 0.5rem 0.75rem; - border-radius: 6px; +.task-checkbox { + position: absolute; + opacity: 0; cursor: pointer; - transition: all 0.2s; - font-size: 0.875rem; - flex-shrink: 0; + width: 24px; + height: 24px; } -.task-item button:hover { - background: #c5221f; -} - -.task-item button:active { - transform: scale(0.95); -} - -/* Task Filters */ -.task-filters { +.checkbox-label { display: flex; - gap: 0.5rem; - margin-top: 2rem; - padding-top: 2rem; - border-top: 1px solid #e0e0e0; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: var(--input-bg); + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); +} + +.checkbox-icon { + opacity: 0; + transform: scale(0); + transition: all var(--transition-fast); + color: white; +} + +.task-checkbox:checked + .checkbox-label { + background: var(--success-color); + border-color: var(--success-color); +} + +.task-checkbox:checked + .checkbox-label .checkbox-icon { + opacity: 1; + transform: scale(1); +} + +.task-checkbox:focus + .checkbox-label { + box-shadow: 0 0 0 3px var(--accent-light); +} + +.checkbox-label:hover { + border-color: var(--accent-color); +} + +/* Task Content */ +.task-content { + flex: 1; + min-width: 0; +} + +.task-text-wrapper { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.task-text { + font-size: 1rem; + color: var(--text-primary); + line-height: 1.5; + word-break: break-word; + transition: all var(--transition-fast); +} + +.task-item.completed .task-text { + text-decoration: line-through; + color: var(--text-secondary); +} + +.task-meta { + display: flex; + align-items: center; + gap: var(--space-md); flex-wrap: wrap; } -[data-theme="dark"] .task-filters { - border-top-color: #3c4043; +.task-category { + display: inline-flex; + padding: 2px 8px; + background: var(--accent-light); + color: var(--accent-color); + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 600; } -.task-filters button { - padding: 0.5rem 1rem; - background: #f8f9fa; - color: #5f6368; - border: 1px solid #e0e0e0; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s; - font-size: 0.875rem; - font-weight: 500; -} - -[data-theme="dark"] .task-filters button { - background: #202124; - color: #9aa0a6; - border-color: #3c4043; -} - -.task-filters button:hover { - background: #e8f0fe; - color: #1a73e8; - border-color: #1a73e8; -} - -[data-theme="dark"] .task-filters button:hover { - background: #1e3a5f; - color: #8ab4f8; - border-color: #8ab4f8; -} - -.task-filters button.active { - background: #1a73e8; - color: white; - border-color: #1a73e8; -} - -[data-theme="dark"] .task-filters button.active { - background: #8ab4f8; - color: #202124; - border-color: #8ab4f8; -} - -.task-filters button:active { - transform: scale(0.98); -} - -/* Stats */ -.task-stats { - display: flex; - gap: 1rem; - margin-top: 1rem; - font-size: 0.875rem; - color: #5f6368; -} - -[data-theme="dark"] .task-stats { - color: #9aa0a6; -} - -.task-stats span { +.task-due-date { display: flex; align-items: center; - gap: 0.25rem; + gap: 4px; + font-size: 0.75rem; + color: var(--text-secondary); } -/* Scrollbar */ -.tasks-container::-webkit-scrollbar { +.task-edit-input { + width: 100%; + padding: var(--space-xs) var(--space-sm); + background: var(--input-bg); + border: 2px solid var(--input-focus-border); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 1rem; + font-family: inherit; +} + +.task-edit-input:focus { + outline: none; + box-shadow: 0 0 0 3px var(--accent-light); +} + +/* Task Actions */ +.task-actions { + display: flex; + gap: var(--space-xs); + opacity: 0; + visibility: hidden; + transition: all var(--transition-fast); +} + +.action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background: var(--secondary-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.action-btn:hover { + background: var(--bg-hover); + border-color: var(--accent-color); + color: var(--accent-color); + transform: scale(1.1); +} + +.action-btn.active, +.priority-btn.active { + background: var(--warning-color); + border-color: var(--warning-color); + color: white; +} + +.delete-btn:hover { + background: var(--error-color); + border-color: var(--error-color); + color: white; +} + +/* ============================================ */ +/* EMPTY STATE */ +/* ============================================ */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-2xl); + text-align: center; + color: var(--text-secondary); +} + +.empty-state svg { + margin-bottom: var(--space-lg); + color: var(--text-tertiary); +} + +.empty-state h3 { + margin: 0 0 var(--space-sm) 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); +} + +.empty-state p { + margin: 0; + font-size: 0.875rem; + color: var(--text-secondary); +} + +/* ============================================ */ +/* TASKS FOOTER */ +/* ============================================ */ +.tasks-footer { + background: var(--secondary-bg); + border-top: 1px solid var(--border-color); + padding: var(--space-lg) var(--space-xl); +} + +.tasks-footer > div { + max-width: 1200px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); +} + +.footer-info { + flex: 1; +} + +.info-text { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.info-text strong { + color: var(--accent-color); + font-weight: 700; +} + +.footer-actions { + display: flex; + gap: var(--space-sm); +} + +.footer-actions button { + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.875rem; +} + +/* ============================================ */ +/* SCROLLBAR */ +/* ============================================ */ +.tasks-main::-webkit-scrollbar { width: 8px; } -.tasks-container::-webkit-scrollbar-track { - background: transparent; +.tasks-main::-webkit-scrollbar-track { + background: var(--scrollbar-track); } -.tasks-container::-webkit-scrollbar-thumb { - background: rgba(128, 128, 128, 0.3); - border-radius: 4px; +.tasks-main::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: var(--radius-full); } -.tasks-container::-webkit-scrollbar-thumb:hover { - background: rgba(128, 128, 128, 0.5); +.tasks-main::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); } -[data-theme="dark"] .tasks-container::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); +/* ============================================ */ +/* RESPONSIVE DESIGN */ +/* ============================================ */ +@media (max-width: 768px) { + .tasks-header, + .task-input-section, + .filter-tabs, + .tasks-main, + .tasks-footer { + padding-left: var(--space-md); + padding-right: var(--space-md); + } + + .header-content { + flex-direction: column; + align-items: flex-start; + gap: var(--space-md); + } + + .header-stats { + width: 100%; + justify-content: space-around; + } + + .input-wrapper { + flex-direction: column; + } + + .task-input { + width: 100%; + } + + .add-task-btn { + width: 100%; + justify-content: center; + } + + .filter-tabs { + gap: var(--space-xs); + padding-left: var(--space-md); + padding-right: var(--space-md); + } + + .filter-tab { + padding: var(--space-sm) var(--space-md); + } + + .task-actions { + opacity: 1; + visibility: visible; + flex-direction: column; + } + + .footer-actions { + flex-direction: column; + width: 100%; + } + + .footer-actions button { + width: 100%; + justify-content: center; + } } -[data-theme="dark"] .tasks-container::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.3); +@media (max-width: 480px) { + .tasks-title { + font-size: 1.25rem; + } + + .tasks-icon { + width: 40px; + height: 40px; + font-size: 1.5rem; + } + + .stat-value { + font-size: 1.25rem; + } + + .header-stats { + gap: var(--space-md); + } + + .task-item { + padding: var(--space-md); + } + + .tasks-footer > div { + flex-direction: column; + align-items: stretch; + } + + .footer-info { + text-align: center; + } } -/* Headers */ -h2 { - margin: 0 0 1.5rem 0; - font-size: 1.75rem; - font-weight: 500; -} - -/* Alpine.js cloak */ +/* ============================================ */ +/* ALPINE.JS CLOAK */ +/* ============================================ */ [x-cloak] { display: none !important; } -/* Responsive */ -@media (max-width: 768px) { - .tasks-container { - padding: 1rem; +/* ============================================ */ +/* ANIMATIONS */ +/* ============================================ */ +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); } - - .task-input { - flex-direction: column; - } - - .task-input button { - width: 100%; + to { + opacity: 1; + transform: translateY(0); + } +} + +.task-item { + animation: slideIn var(--transition-smooth) ease-out; +} + +/* ============================================ */ +/* PRINT STYLES */ +/* ============================================ */ +@media print { + .tasks-header, + .task-input-section, + .filter-tabs, + .task-actions, + .tasks-footer { + display: none !important; + } + + .task-item { + break-inside: avoid; + border: 1px solid #ccc; + margin-bottom: 8px; + } + + .task-item:hover { + transform: none; + box-shadow: none; } } diff --git a/web/desktop/tasks/tasks.html b/web/desktop/tasks/tasks.html index 436e594f9..3629b46ef 100644 --- a/web/desktop/tasks/tasks.html +++ b/web/desktop/tasks/tasks.html @@ -1,37 +1,265 @@
-

Tasks

-
- - -
+ +
+
+

+ + Tasks +

+
+ + + Total + + + + Active + + + + Done + +
+
+
-
    - -
+ +
+
+ + + + + + + +
+
-
- - - - -
+ +
+ + + + +
+ + +
+
+ + + +
+
+ + +