From 0c8a671cf06a3d7cb70e8c849956da835b34285d Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:09:16 +0100 Subject: [PATCH 001/172] feat: add main application entry point with logger initialization --- web-ui/src/main.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 web-ui/src/main.rs diff --git a/web-ui/src/main.rs b/web-ui/src/main.rs new file mode 100644 index 0000000..969d60c --- /dev/null +++ b/web-ui/src/main.rs @@ -0,0 +1,17 @@ +mod components; +mod services; +mod styles; +mod types; + +use components::app::App; +use wasm_logger::init; +use yew::Renderer; + +fn main() { + // Initialize logger for debugging + init(wasm_logger::Config::new(log::Level::Debug)); + console_error_panic_hook::set_once(); + + // Mount the app to the DOM + Renderer::::new().render(); +} \ No newline at end of file From 76cf74a73dc8d8938a97b449ecca2b0b8ab33560 Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:09:29 +0100 Subject: [PATCH 002/172] feat: enhance server to support WebSocket connections and improve error handling --- src/bin/server/main.rs | 128 +++++++++++++++++++++++++++++++++++------ 1 file changed, 110 insertions(+), 18 deletions(-) diff --git a/src/bin/server/main.rs b/src/bin/server/main.rs index c02208c..24d790a 100644 --- a/src/bin/server/main.rs +++ b/src/bin/server/main.rs @@ -1,38 +1,130 @@ pub mod connection; pub mod group; pub mod group_table; +pub mod websocket; use connection::serve; +use websocket::{handle_websocket_connection, is_websocket_upgrade, start_websocket_server}; use async_std::net::TcpListener; use async_std::prelude::*; use async_std::task; +use log::{error, info}; use std::sync::Arc; /// The main entry point for the async-chat server. /// -/// Accepts incoming TCP connections and spawns a task to handle each. -/// Expects one argument: the address to bind to (e.g., `127.0.0.1:8080`) +/// Accepts incoming TCP and WebSocket connections and spawns tasks to handle each. +/// Supports both existing CLI clients and new web clients. +/// +/// Usage: server +/// Example: server 127.0.0.1:8080 127.0.0.1:8081 fn main() -> anyhow::Result<()> { - let address = std::env::args().nth(1).expect( - "Usage: server - ADDRESS", - ); + // Initialize logger + env_logger::init(); + + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: {} [WS_ADDRESS]", args[0]); + eprintln!("Example: {} 127.0.0.1:8080 127.0.0.1:8081", args[0]); + eprintln!("If WS_ADDRESS is omitted, TCP_ADDRESS+1 will be used for WebSocket"); + std::process::exit(1); + } + + let tcp_address = args[1].clone(); + let ws_address = if args.len() > 2 { + args[2].clone() + } else { + // Auto-generate WebSocket address (TCP port + 1) + format!( + "127.0.0.1:{}", + tcp_address + .split(':') + .last() + .unwrap_or("8080") + .parse::() + .unwrap_or(8080) + + 1 + ) + }; + // A thread-safe table that stores all active chat groups by name. let chat_group_table = Arc::new(group_table::GroupTable::new()); - async_std::task::block_on(async { - let listener = TcpListener::bind(address).await?; - let mut new_connections = listener.incoming(); - // Accept incoming connections and spawn an asynchronous task to handle each - while let Some(socket_result) = new_connections.next().await { - let socket = socket_result?; - let groups = chat_group_table.clone(); - task::spawn(async { - log_error(serve(socket, groups).await); - }); + + // Create a runtime that can handle both async-std and tokio + let rt = tokio::runtime::Runtime::new()?; + + rt.block_on(async { + let groups_clone = chat_group_table.clone(); + let ws_addr_clone = ws_address.clone(); + + // Start WebSocket server in background + let ws_handle = tokio::spawn(async move { + if let Err(e) = start_websocket_server(&ws_addr_clone, groups_clone).await { + error!("WebSocket server error: {}", e); + } + }); + + // Give WebSocket server a moment to start + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + info!("Starting TCP server on: {}", tcp_address); + info!("WebSocket server listening on: {}", ws_address); + info!("You can connect CLI clients to: {}", tcp_address); + info!("Web clients can connect to: {}", ws_address); + + // Handle TCP connections in async-std runtime + async_std::task::spawn(async move { + if let Err(e) = run_tcp_server(tcp_address, chat_group_table).await { + error!("TCP server error: {}", e); + } + }); + + // Wait for WebSocket server (this will run forever) + if let Err(e) = ws_handle.await { + error!("WebSocket server task error: {}", e); } - Ok(()) - }) + + Ok::<(), anyhow::Error>(()) + })?; + + Ok(()) +} + +/// Run the TCP server for CLI clients +async fn run_tcp_server(address: String, chat_group_table: Arc) -> anyhow::Result<()> { + let listener = TcpListener::bind(&address).await?; + let mut new_connections = listener.incoming(); + + info!("TCP server listening on: {}", address); + + // Accept incoming connections and spawn an asynchronous task to handle each + while let Some(socket_result) = new_connections.next().await { + let socket = socket_result?; + info!("New TCP connection from: {}", socket.peer_addr()?); + + // Check if this might be a WebSocket upgrade request + if let Ok(is_ws) = is_websocket_upgrade(&socket).await { + if is_ws { + info!("Detected WebSocket upgrade request on TCP port, handling as WebSocket"); + let groups = chat_group_table.clone(); + task::spawn(async { + if let Err(e) = handle_websocket_connection(socket, groups).await { + error!("WebSocket connection error: {}", e); + } + }); + continue; + } + } + + // Handle as regular TCP connection + let groups = chat_group_table.clone(); + task::spawn(async { + log_error(serve(socket, groups).await); + }); + } + Ok(()) } /// Logs errors from client handler tasks. From c96ee136c652c67b6b297000a93aa22e64cfeac0 Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:09:42 +0100 Subject: [PATCH 003/172] feat: implement trait-based outbound connection with TCP support --- src/bin/server/outbound.rs | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/bin/server/outbound.rs diff --git a/src/bin/server/outbound.rs b/src/bin/server/outbound.rs new file mode 100644 index 0000000..d14be80 --- /dev/null +++ b/src/bin/server/outbound.rs @@ -0,0 +1,45 @@ +use async_chat::{FromServer, utils}; +use async_std::net::TcpStream; +use async_std::prelude::*; +use std::sync::Arc; + +/// Trait for outbound connections that can send messages to clients +#[async_trait::async_trait] +pub trait OutboundConnection: Send + Sync { + async fn send(&self, packet: FromServer) -> anyhow::Result<()>; +} + +/// TCP outbound connection implementation +pub struct TcpOutbound { + stream: Arc>, +} + +impl TcpOutbound { + pub fn new(to_client: TcpStream) -> Self { + Self { + stream: Arc::new(async_std::sync::Mutex::new(to_client)), + } + } +} + +#[async_trait::async_trait] +impl OutboundConnection for TcpOutbound { + async fn send(&self, packet: FromServer) -> anyhow::Result<()> { + let mut guard = self.stream.lock().await; + utils::send_as_json(&mut *guard, &packet).await?; + guard.flush().await?; + Ok(()) + } +} + +/// Convert from the original Outbound to our new trait-based approach +impl From for Arc { + fn from(original: crate::connection::Outbound) -> Self { + // We can't directly convert because the original Outbound doesn't expose the stream + // For now, we'll need to modify the original approach + panic!("This conversion is not yet implemented. Please update the connection handling.") + } +} + +// We'll also need to add async_trait to dependencies +// For now, let's create a simpler approach with enum-based outbound From 963c21064bc23ab3a83c36ac59b7771c6c098c85 Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:10:09 +0100 Subject: [PATCH 004/172] feat: add chat message and group structures with WebSocket support --- web-ui/src/types.rs | 264 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 web-ui/src/types.rs diff --git a/web-ui/src/types.rs b/web-ui/src/types.rs new file mode 100644 index 0000000..a1f51fd --- /dev/null +++ b/web-ui/src/types.rs @@ -0,0 +1,264 @@ +use async_chat::{FromClient, FromServer}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Enhanced chat message with additional UI information +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChatMessage { + pub id: String, + pub group_name: String, + pub content: String, + pub sender: String, + pub timestamp: DateTime, + pub is_own: bool, +} + +impl ChatMessage { + pub fn new(group_name: String, content: String, sender: String, is_own: bool) -> Self { + Self { + id: Uuid::new_v4().to_string(), + group_name, + content, + sender, + timestamp: Utc::now(), + is_own, + } + } + + pub fn from_server_message( + group_name: String, + message: String, + sender: String, + current_user: String, + ) -> Self { + Self { + id: Uuid::new_v4().to_string(), + group_name, + content: message, + sender, + timestamp: Utc::now(), + is_own: sender == current_user, + } + } +} + +/// Chat group information for UI display +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChatGroup { + pub name: String, + pub member_count: usize, + pub last_message: Option, + pub last_activity: DateTime, + pub unread_count: usize, +} + +impl ChatGroup { + pub fn new(name: String) -> Self { + Self { + name, + member_count: 1, + last_message: None, + last_activity: Utc::now(), + unread_count: 0, + } + } + + pub fn update_activity(&mut self, message: &str) { + self.last_message = Some(message.to_string()); + self.last_activity = Utc::now(); + } + + pub fn increment_unread(&mut self) { + self.unread_count += 1; + } + + pub fn clear_unread(&mut self) { + self.unread_count = 0; + } +} + +/// WebSocket connection status +#[derive(Debug, Clone, PartialEq)] +pub enum ConnectionStatus { + Connecting, + Connected, + Disconnected, + Error(String), +} + +impl ConnectionStatus { + pub fn is_connected(&self) -> bool { + matches!(self, ConnectionStatus::Connected) + } + + pub fn to_string(&self) -> String { + match self { + ConnectionStatus::Connecting => "Connecting...".to_string(), + ConnectionStatus::Connected => "Connected".to_string(), + ConnectionStatus::Disconnected => "Disconnected".to_string(), + ConnectionStatus::Error(msg) => format!("Error: {}", msg), + } + } +} + +/// Application-wide state +#[derive(Debug, Clone)] +pub struct AppState { + pub current_user: String, + pub current_group: Option, + pub connection_status: ConnectionStatus, + pub groups: Vec, + pub messages: Vec, + pub theme: Theme, +} + +impl Default for AppState { + fn default() -> Self { + Self { + current_user: format!("User_{}", Uuid::new_v4().to_string()[..8].to_uppercase()), + current_group: None, + connection_status: ConnectionStatus::Disconnected, + groups: Vec::new(), + messages: Vec::new(), + theme: Theme::Dark, + } + } +} + +/// Theme settings +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum Theme { + Light, + Dark, +} + +impl Theme { + pub fn toggle(&self) -> Self { + match self { + Theme::Light => Theme::Dark, + Theme::Dark => Theme::Light, + } + } +} + +/// Application actions for state management +#[derive(Debug, Clone)] +pub enum AppAction { + SetUser(String), + SetConnectionStatus(ConnectionStatus), + JoinGroup(String), + LeaveGroup(String), + AddMessage(ChatMessage), + SetCurrentGroup(Option), + UpdateGroup(ChatGroup), + ClearUnread(String), + SetTheme(Theme), +} + +/// Message types for WebSocket communication with enhanced data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum EnhancedFromClient { + /// Join a group with user identification + Join { + group_name: String, + user_id: String, + username: String, + }, + /// Post a message to a group + Post { + group_name: String, + message: String, + user_id: String, + username: String, + }, + /// Leave a group + Leave { group_name: String, user_id: String }, + /// Get list of active groups + GetGroups, + /// Typing indicator + Typing { + group_name: String, + username: String, + is_typing: bool, + }, +} + +impl From for FromClient { + fn from(enhanced: EnhancedFromClient) -> Self { + match enhanced { + EnhancedFromClient::Join { group_name, .. } => FromClient::Join { + group_name: std::sync::Arc::new(group_name), + }, + EnhancedFromClient::Post { + group_name, + message, + .. + } => FromClient::Post { + group_name: std::sync::Arc::new(group_name), + message: std::sync::Arc::new(message), + }, + _ => FromClient::Join { + group_name: std::sync::Arc::new("unknown".to_string()), + }, // Fallback + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum EnhancedFromServer { + /// Enhanced message with sender information + Message { + group_name: String, + message: String, + sender: String, + timestamp: DateTime, + message_id: String, + }, + /// User joined a group + UserJoined { + group_name: String, + username: String, + }, + /// User left a group + UserLeft { + group_name: String, + username: String, + }, + /// List of available groups + GroupsList(Vec), + /// Group member count update + GroupUpdate { + group_name: String, + member_count: usize, + }, + /// User is typing + UserTyping { + group_name: String, + username: String, + }, + /// Server error + Error(String), + /// Connection successful + Connected { user_id: String, message: String }, +} + +impl From for EnhancedFromServer { + fn from(server: FromServer) -> Self { + match server { + FromServer::Message { + group_name, + message, + } => { + EnhancedFromServer::Message { + group_name: (*group_name).clone(), + message: (*message).clone(), + sender: "Unknown".to_string(), // Server doesn't send sender info yet + timestamp: Utc::now(), + message_id: Uuid::new_v4().to_string(), + } + } + FromServer::Error(msg) => EnhancedFromServer::Error(msg), + } + } +} From 3cafe987dc9d12776a92973e60713601400982dc Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:10:24 +0100 Subject: [PATCH 005/172] refactor: simplify main function to focus on TCP server handling --- src/bin/server/main.rs | 125 +++++------------------------------------ 1 file changed, 15 insertions(+), 110 deletions(-) diff --git a/src/bin/server/main.rs b/src/bin/server/main.rs index 24d790a..ae7c793 100644 --- a/src/bin/server/main.rs +++ b/src/bin/server/main.rs @@ -1,130 +1,35 @@ pub mod connection; pub mod group; pub mod group_table; -pub mod websocket; use connection::serve; -use websocket::{handle_websocket_connection, is_websocket_upgrade, start_websocket_server}; use async_std::net::TcpListener; use async_std::prelude::*; use async_std::task; -use log::{error, info}; use std::sync::Arc; /// The main entry point for the async-chat server. /// -/// Accepts incoming TCP and WebSocket connections and spawns tasks to handle each. -/// Supports both existing CLI clients and new web clients. -/// -/// Usage: server -/// Example: server 127.0.0.1:8080 127.0.0.1:8081 +/// Accepts incoming TCP connections and spawns a task to handle each. +/// Expects one argument: the address to bind to (e.g., `127.0.0.1:8080`) fn main() -> anyhow::Result<()> { - // Initialize logger - env_logger::init(); - - let args: Vec = std::env::args().collect(); - - if args.len() < 2 { - eprintln!("Usage: {} [WS_ADDRESS]", args[0]); - eprintln!("Example: {} 127.0.0.1:8080 127.0.0.1:8081", args[0]); - eprintln!("If WS_ADDRESS is omitted, TCP_ADDRESS+1 will be used for WebSocket"); - std::process::exit(1); - } - - let tcp_address = args[1].clone(); - let ws_address = if args.len() > 2 { - args[2].clone() - } else { - // Auto-generate WebSocket address (TCP port + 1) - format!( - "127.0.0.1:{}", - tcp_address - .split(':') - .last() - .unwrap_or("8080") - .parse::() - .unwrap_or(8080) - + 1 - ) - }; - + let address = std::env::args().nth(1).expect("Usage: server ADDRESS"); // A thread-safe table that stores all active chat groups by name. let chat_group_table = Arc::new(group_table::GroupTable::new()); - - // Create a runtime that can handle both async-std and tokio - let rt = tokio::runtime::Runtime::new()?; - - rt.block_on(async { - let groups_clone = chat_group_table.clone(); - let ws_addr_clone = ws_address.clone(); - - // Start WebSocket server in background - let ws_handle = tokio::spawn(async move { - if let Err(e) = start_websocket_server(&ws_addr_clone, groups_clone).await { - error!("WebSocket server error: {}", e); - } - }); - - // Give WebSocket server a moment to start - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - info!("Starting TCP server on: {}", tcp_address); - info!("WebSocket server listening on: {}", ws_address); - info!("You can connect CLI clients to: {}", tcp_address); - info!("Web clients can connect to: {}", ws_address); - - // Handle TCP connections in async-std runtime - async_std::task::spawn(async move { - if let Err(e) = run_tcp_server(tcp_address, chat_group_table).await { - error!("TCP server error: {}", e); - } - }); - - // Wait for WebSocket server (this will run forever) - if let Err(e) = ws_handle.await { - error!("WebSocket server task error: {}", e); + async_std::task::block_on(async { + let listener = TcpListener::bind(address).await?; + let mut new_connections = listener.incoming(); + // Accept incoming connections and spawn an asynchronous task to handle each + while let Some(socket_result) = new_connections.next().await { + let socket = socket_result?; + let groups = chat_group_table.clone(); + task::spawn(async { + log_error(serve(socket, groups).await); + }); } - - Ok::<(), anyhow::Error>(()) - })?; - - Ok(()) -} - -/// Run the TCP server for CLI clients -async fn run_tcp_server(address: String, chat_group_table: Arc) -> anyhow::Result<()> { - let listener = TcpListener::bind(&address).await?; - let mut new_connections = listener.incoming(); - - info!("TCP server listening on: {}", address); - - // Accept incoming connections and spawn an asynchronous task to handle each - while let Some(socket_result) = new_connections.next().await { - let socket = socket_result?; - info!("New TCP connection from: {}", socket.peer_addr()?); - - // Check if this might be a WebSocket upgrade request - if let Ok(is_ws) = is_websocket_upgrade(&socket).await { - if is_ws { - info!("Detected WebSocket upgrade request on TCP port, handling as WebSocket"); - let groups = chat_group_table.clone(); - task::spawn(async { - if let Err(e) = handle_websocket_connection(socket, groups).await { - error!("WebSocket connection error: {}", e); - } - }); - continue; - } - } - - // Handle as regular TCP connection - let groups = chat_group_table.clone(); - task::spawn(async { - log_error(serve(socket, groups).await); - }); - } - Ok(()) + Ok(()) + }) } /// Logs errors from client handler tasks. From 859c6f86147248528b75ab22ffbe28ad7a4931ae Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:10:33 +0100 Subject: [PATCH 006/172] refactor: remove TcpOutbound implementation and associated traits --- src/bin/server/outbound.rs | 45 -------------------------------------- 1 file changed, 45 deletions(-) delete mode 100644 src/bin/server/outbound.rs diff --git a/src/bin/server/outbound.rs b/src/bin/server/outbound.rs deleted file mode 100644 index d14be80..0000000 --- a/src/bin/server/outbound.rs +++ /dev/null @@ -1,45 +0,0 @@ -use async_chat::{FromServer, utils}; -use async_std::net::TcpStream; -use async_std::prelude::*; -use std::sync::Arc; - -/// Trait for outbound connections that can send messages to clients -#[async_trait::async_trait] -pub trait OutboundConnection: Send + Sync { - async fn send(&self, packet: FromServer) -> anyhow::Result<()>; -} - -/// TCP outbound connection implementation -pub struct TcpOutbound { - stream: Arc>, -} - -impl TcpOutbound { - pub fn new(to_client: TcpStream) -> Self { - Self { - stream: Arc::new(async_std::sync::Mutex::new(to_client)), - } - } -} - -#[async_trait::async_trait] -impl OutboundConnection for TcpOutbound { - async fn send(&self, packet: FromServer) -> anyhow::Result<()> { - let mut guard = self.stream.lock().await; - utils::send_as_json(&mut *guard, &packet).await?; - guard.flush().await?; - Ok(()) - } -} - -/// Convert from the original Outbound to our new trait-based approach -impl From for Arc { - fn from(original: crate::connection::Outbound) -> Self { - // We can't directly convert because the original Outbound doesn't expose the stream - // For now, we'll need to modify the original approach - panic!("This conversion is not yet implemented. Please update the connection handling.") - } -} - -// We'll also need to add async_trait to dependencies -// For now, let's create a simpler approach with enum-based outbound From 12b2021030a64e68cd263a6ecb93bac5a82dc889 Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:10:43 +0100 Subject: [PATCH 007/172] feat: add initial HTML structure for the web UI --- web-ui/index.html | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 web-ui/index.html diff --git a/web-ui/index.html b/web-ui/index.html new file mode 100644 index 0000000..bb5fb6a --- /dev/null +++ b/web-ui/index.html @@ -0,0 +1,13 @@ + + + + + + Async Chat - Modern Web UI + + + + +
+ + \ No newline at end of file From dcb3255429bed7088af6b77ad864b4d1127515ce Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:12:29 +0100 Subject: [PATCH 008/172] chore(web-ui): add Cargo.toml for async-chat-web project - Define package metadata including name, version, edition, and author - Add essential dependencies for Yew framework and WebAssembly support - Include serialization libraries serde and serde_json - Add stylist for CSS-in-Rust with Yew integration - Include chrono and uuid with wasm-bindgen features - Add logging and panic hook crates for better debugging - Specify web-sys features for browser APIs like WebSocket and DOM events - Link local async-chat dependency via relative path reference --- web-ui/Cargo.toml | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 web-ui/Cargo.toml diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml new file mode 100644 index 0000000..9a14b14 --- /dev/null +++ b/web-ui/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "async-chat-web" +version = "0.1.0" +edition = "2021" +authors = ["Christian yemele "] + +[dependencies] +yew = { version = "0.21", features = ["csr"] } +web-sys = "0.3" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +gloo-net = "0.5" +gloo-timers = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +stylist = { version = "0.13", features = ["yew"] } +chrono = { version = "0.4", features = ["wasmbind"] } +uuid = { version = "1.0", features = ["v4", "wasm-bindgen"] } +log = "0.4" +wasm-logger = "0.2" +console_error_panic_hook = "0.1" + +[dependencies.async-chat] +path = ".." + +[dependencies.web-sys] +version = "0.3" +features = [ + "MessageEvent", + "WebSocket", + "CloseEvent", + "ErrorEvent", + "console", + "Document", + "Element", + "HtmlElement", + "HtmlInputElement", + "Window", + "KeyboardEvent", + "Storage", + "Location", + "History", +] \ No newline at end of file From 64f57d5cda1aed34cf2b9cd5e32eec1404169e2b Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:12:44 +0100 Subject: [PATCH 009/172] chore(build): add initial Trunk.toml configuration - Define build target as index.html for bundling - Set build mode to non-release by default - Specify output directory for build assets - Configure watch paths to include src directory - Set serve address to localhost and port to 8080 - Establish clean settings for output directory with optional cargo cleanup flag --- web-ui/Trunk.toml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 web-ui/Trunk.toml diff --git a/web-ui/Trunk.toml b/web-ui/Trunk.toml new file mode 100644 index 0000000..f2a9a96 --- /dev/null +++ b/web-ui/Trunk.toml @@ -0,0 +1,25 @@ +[build] +# The index HTML file to drive the bundling process. +target = "index.html" + +# Build in release mode. +release = false + +# The output dir for all final assets. +dist = "dist" + +[watch] +# Paths to watch. The `build.target`'s parent folder is watched by default. +watch = ["src"] + +[serve] +# The address to serve on. +address = "127.0.0.1" +# The port to serve on. +port = 8080 + +[clean] +# The output dir. +dist = "dist" +# Optionally perform additional explicit file cleanups. +cargo = false \ No newline at end of file From 416b326b8b395844ade64816c22e3a25fb0df2be Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:12:54 +0100 Subject: [PATCH 010/172] feat(app): implement main application component with connection handling - Add App component with state management using Yew hooks - Integrate connection service for WebSocket and HTTP fallback - Implement automatic connection attempt on mount with error logging - Add Header, GroupSidebar, and ChatRoom child components - Handle server messages to update chat state and group activity - Manage connection status changes in the application state - Include styling with global CSS and support theme switching - Add callbacks for group selection, joining, and message sending - Provide basic unit test for App component initialization --- web-ui/src/components/app.rs | 172 +++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 web-ui/src/components/app.rs diff --git a/web-ui/src/components/app.rs b/web-ui/src/components/app.rs new file mode 100644 index 0000000..879ffc1 --- /dev/null +++ b/web-ui/src/components/app.rs @@ -0,0 +1,172 @@ +use crate::services::use_connection_service; +use crate::types::*; +use async_chat::{FromClient, FromServer}; +use stylist::{Style, yew::styled_component}; +use web_sys::console; +use yew::prelude::*; +use yew::{Component, Context, Html, html}; + +// Import child components +use super::chat_room::ChatRoom; +use super::group_sidebar::GroupSidebar; +use super::header::Header; + +/// Main application styles +const APP_STYLE: &str = include_str!("../styles/global.css"); + +#[styled_component(App)] +pub fn App() -> Html { + let state = use_state(|| AppState::default()); + let connection_service = use_connection_service( + Callback::from({ + let state = state.clone(); + move |message: FromServer| { + handle_server_message(message, &state); + } + }), + Callback::from({ + let state = state.clone(); + move |status: ConnectionStatus| { + handle_connection_status_change(status, &state); + } + }), + ); + + // Auto-connect on component mount + { + let mut service = (*connection_service).clone(); + use_effect_with((), move |_| { + wasm_bindgen_futures::spawn_local(async move { + // Try WebSocket first, fall back to HTTP simulation + if let Err(e) = service.connect("ws://127.0.0.1:8081").await { + console::log_1( + &format!("WebSocket connection failed: {}. Using HTTP simulation.", e) + .into(), + ); + service.set_http_mode(); + if let Err(e) = service.connect("http://127.0.0.1:8080").await { + console::log_1(&format!("HTTP simulation failed: {}", e).into()); + } + } + }); + || () + }); + } + + let current_state = (*state).clone(); + let service = (*connection_service).clone(); + + html! { +
+ + +
+ +
+ | { + state.set(AppState { + current_group: group_name, + ..current_state.clone() + }); + })} + on_join_group={Callback::from(move |group_name: String| { + let mut s = service.clone(); + wasm_bindgen_futures::spawn_local(async move { + let message = FromClient::Join { + group_name: std::sync::Arc::new(group_name), + }; + if let Err(e) = s.send(message).await { + console::log_1(&format!("Failed to join group: {}", e).into()); + } + }); + })} + /> + + +
+
+ } +} + +/// Handle incoming messages from server +fn handle_server_message(message: FromServer, state: &UseStateHandle) { + let mut current_state = (*state).clone(); + + match message { + FromServer::Message { + group_name, + message, + } => { + let chat_message = ChatMessage::from_server_message( + (*group_name).clone(), + (*message).clone(), + "Server".to_string(), // Server doesn't send sender info yet + current_state.current_user.clone(), + ); + + current_state.messages.push(chat_message); + + // Update group's last message + if let Some(group) = current_state + .groups + .iter_mut() + .find(|g| g.name == *group_name) + { + group.update_activity(&(*message)); + } + } + FromServer::Error(error) => { + console::log_1(&format!("Server error: {}", error).into()); + } + } + + state.set(current_state); +} + +/// Handle connection status changes +fn handle_connection_status_change(status: ConnectionStatus, state: &UseStateHandle) { + let mut current_state = (*state).clone(); + current_state.connection_status = status; + state.set(current_state); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_app_initialization() { + // Basic test to ensure component can be created + let _app = App(); + } +} From 5597e12766e4f96932b867d716b6ac2490fe3ade Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:13:22 +0100 Subject: [PATCH 011/172] feat(header): add responsive header with user info and theme toggle - Implement header component with styled layout and flexbox alignment - Display current user with avatar showing initial letter - Show connection status with color-coded indicator and text - Add theme toggle button to switch between dark and light modes - Apply CSS custom properties for colors, fonts, spacing, and transitions - Include animated pulse effect for connecting status indicator - Use yew framework and stylist for styled components and properties management --- web-ui/src/components/header.rs | 203 ++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 web-ui/src/components/header.rs diff --git a/web-ui/src/components/header.rs b/web-ui/src/components/header.rs new file mode 100644 index 0000000..3768c04 --- /dev/null +++ b/web-ui/src/components/header.rs @@ -0,0 +1,203 @@ +use crate::types::{ConnectionStatus, Theme}; +use stylist::yew::styled_component; +use yew::prelude::*; + +#[derive(Properties, PartialEq)] +pub struct HeaderProps { + pub current_user: String, + pub connection_status: ConnectionStatus, + pub theme: Theme, + pub on_theme_change: Callback, +} + +#[styled_component(Header)] +pub fn Header(props: &HeaderProps) -> Html { + let theme = props.theme; + + let header_style = stylist::Style::new( + r#" + .header { + background-color: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border-primary); + padding: var(--spacing-sm) var(--spacing-md); + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + min-height: 48px; + flex-shrink: 0; + } + + .header-left { + display: flex; + align-items: center; + gap: var(--spacing-md); + } + + .header-title { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } + + .header-right { + display: flex; + align-items: center; + gap: var(--spacing-md); + } + + .user-info { + display: flex; + align-items: center; + gap: var(--spacing-sm); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + } + + .user-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + background-color: var(--color-primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + } + + .connection-status { + display: flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--border-radius-sm); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + } + + .status-connected { + background-color: rgba(59, 165, 92, 0.1); + color: var(--color-success); + } + + .status-connecting { + background-color: rgba(250, 166, 26, 0.1); + color: var(--color-warning); + } + + .status-disconnected { + background-color: rgba(237, 66, 69, 0.1); + color: var(--color-danger); + } + + .status-error { + background-color: rgba(237, 66, 69, 0.1); + color: var(--color-danger); + } + + .status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + } + + .indicator-connected { + background-color: var(--color-success); + } + + .indicator-connecting { + background-color: var(--color-warning); + animation: pulse 1.5s infinite; + } + + .indicator-disconnected { + background-color: var(--color-danger); + } + + .indicator-error { + background-color: var(--color-danger); + } + + .theme-toggle { + background: none; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + padding: var(--spacing-xs); + border-radius: var(--border-radius-sm); + transition: var(--transition-fast); + } + + .theme-toggle:hover { + background-color: var(--color-bg-elevated); + color: var(--color-text-primary); + } + + .theme-icon { + font-size: var(--font-size-lg); + } + "#, + ) + .expect("Failed to create header styles"); + + let toggle_theme = { + let on_theme_change = props.on_theme_change.clone(); + Callback::from(move |_| { + on_theme_change.emit(theme.toggle()); + }) + }; + + let connection_class = match props.connection_status { + ConnectionStatus::Connected => "status-connected", + ConnectionStatus::Connecting => "status-connecting", + ConnectionStatus::Disconnected => "status-disconnected", + ConnectionStatus::Error(_) => "status-error", + }; + + let indicator_class = match props.connection_status { + ConnectionStatus::Connected => "indicator-connected", + ConnectionStatus::Connecting => "indicator-connecting", + ConnectionStatus::Disconnected => "indicator-disconnected", + ConnectionStatus::Error(_) => "indicator-error", + }; + + let status_text = props.connection_status.to_string(); + let user_initial = props + .current_user + .chars() + .next() + .unwrap_or('U') + .to_uppercase(); + let theme_icon = match theme { + Theme::Dark => "🌙", + Theme::Light => "☀️", + }; + + html! { +
+
+
+
{"🔥 Async Chat"}
+
+ +
+ + +
+
+ {status_text} +
+ + +
+
+
+ } +} From 659d3d99557adf43f91d9066f2f968d299e19d44 Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:13:34 +0100 Subject: [PATCH 012/172] chore(git): add .claude to .gitignore - Prevent tracking of .claude files in version control - Update .gitignore with new entry for .claude directory or file --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ea8c4bf..80f4384 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.claude \ No newline at end of file From eae63399005e4d7c41a62832db3e61fd8b8e3259 Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:13:43 +0100 Subject: [PATCH 013/172] feat(components): add mod declarations and re-exports - Declare modules for app, chat_room, group_sidebar, header, message_list, and message_input - Re-export all modules to simplify imports elsewhere in the project --- web-ui/src/components/mod.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 web-ui/src/components/mod.rs diff --git a/web-ui/src/components/mod.rs b/web-ui/src/components/mod.rs new file mode 100644 index 0000000..c12c1c3 --- /dev/null +++ b/web-ui/src/components/mod.rs @@ -0,0 +1,13 @@ +pub mod app; +pub mod chat_room; +pub mod group_sidebar; +pub mod header; +pub mod message_list; +pub mod message_input; + +pub use app::*; +pub use chat_room::*; +pub use group_sidebar::*; +pub use header::*; +pub use message_list::*; +pub use message_input::*; \ No newline at end of file From e0e9e3351670b1140529e0a2e2902f469c3948cb Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:13:55 +0100 Subject: [PATCH 014/172] feat(websocket): add WebSocket and HTTP fallback services for server communication - Implement WebSocketService for real-time messaging with connection lifecycle handling - Add HttpService as a fallback option simulating server responses via HTTP - Create ConnectionService to unify WebSocket and HTTP communication strategies - Provide asynchronous connect and send methods with fallback logic - Expose connection status updates and message callbacks through Yew callbacks - Implement disconnect and connection state query methods - Provide a Yew hook for easy integration of ConnectionService in components --- web-ui/src/services/websocket.rs | 240 +++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 web-ui/src/services/websocket.rs diff --git a/web-ui/src/services/websocket.rs b/web-ui/src/services/websocket.rs new file mode 100644 index 0000000..91a3f43 --- /dev/null +++ b/web-ui/src/services/websocket.rs @@ -0,0 +1,240 @@ +use crate::types::*; +use async_chat::{FromClient, FromServer}; +use gloo_net::websocket::{Event, Message, WebSocket, WebSocketError}; +use std::rc::Rc; +use yew::prelude::*; +use yew::{Callback, NodeRef}; + +/// WebSocket service for real-time communication with server +#[derive(Clone)] +pub struct WebSocketService { + websocket: Option, + on_message_callback: Callback, + on_status_change: Callback, +} + +impl WebSocketService { + /// Create a new WebSocket service + pub fn new( + on_message_callback: Callback, + on_status_change: Callback, + ) -> Self { + Self { + websocket: None, + on_message_callback, + on_status_change, + } + } + + /// Connect to WebSocket server + pub fn connect(&mut self, url: &str) -> Result<(), WebSocketError> { + self.on_status_change.emit(ConnectionStatus::Connecting); + + let ws = WebSocket::new(url)?; + let ws_clone = ws.clone(); + let message_callback = self.on_message_callback.clone(); + let status_callback = self.on_status_change.clone(); + + // Set up message handler + ws.set_binary_message_handler(move |_data| { + log::debug!("Received binary message (not supported)"); + }); + + ws.set_text_message_handler(move |text| { + log::debug!("Received text message: {}", text); + + // Parse the message + match serde_json::from_str::(&text) { + Ok(server_message) => { + message_callback.emit(server_message); + } + Err(e) => { + log::error!("Failed to parse server message: {}", e); + let error_msg = FromServer::Error(format!("Invalid message format: {}", e)); + message_callback.emit(error_msg); + } + } + }); + + // Set up event handlers for connection lifecycle + let status_change_clone = status_callback.clone(); + ws.set_onopen(move || { + log::info!("WebSocket connected"); + status_change_clone.emit(ConnectionStatus::Connected); + }); + + let status_change_clone = status_callback.clone(); + ws.set_onclose(move || { + log::info!("WebSocket disconnected"); + status_change_clone.emit(ConnectionStatus::Disconnected); + }); + + let status_change_clone = status_callback.clone(); + ws.set_onerror(move || { + log::error!("WebSocket error occurred"); + status_change_clone.emit(ConnectionStatus::Error("Connection error".to_string())); + }); + + self.websocket = Some(ws); + Ok(()) + } + + /// Send a message to the server + pub fn send(&self, message: FromClient) -> Result<(), WebSocketError> { + if let Some(ws) = &self.websocket { + let json = serde_json::to_string(&message) + .map_err(|e| WebSocketError::ConnectionError(e.to_string()))?; + ws.send(Message::Text(json)) + } else { + Err(WebSocketError::ConnectionError("Not connected".to_string())) + } + } + + /// Disconnect from server + pub fn disconnect(&mut self) { + if let Some(ws) = self.websocket.take() { + ws.close(); + self.on_status_change.emit(ConnectionStatus::Disconnected); + } + } + + /// Check if connected + pub fn is_connected(&self) -> bool { + self.websocket.is_some() + } +} + +/// HTTP Service for fallback when WebSocket is not available +pub struct HttpService { + on_message_callback: Callback, + on_status_change: Callback, +} + +impl HttpService { + pub fn new( + on_message_callback: Callback, + on_status_change: Callback, + ) -> Self { + Self { + on_message_callback, + on_status_change, + } + } + + /// Send a message via HTTP (simulated) + pub async fn send(&self, message: FromClient) -> Result<(), Box> { + // For now, this is a placeholder that simulates responses + // In a real implementation, you'd make HTTP requests to a REST API + + match message { + FromClient::Join { group_name } => { + // Simulate successful join + let response = FromServer::Message { + group_name, + message: std::sync::Arc::new("You joined the group".to_string()), + }; + self.on_message_callback.emit(response); + } + FromClient::Post { + group_name, + message, + } => { + // Simulate message echo + let response = FromServer::Message { + group_name, + message: message.clone(), + }; + self.on_message_callback.emit(response); + } + } + + self.on_status_change.emit(ConnectionStatus::Connected); + Ok(()) + } +} + +/// Unified connection service that can use WebSocket or HTTP +#[derive(Clone)] +pub struct ConnectionService { + websocket_service: Rc>, + http_service: HttpService, + use_websocket: bool, +} + +impl ConnectionService { + pub fn new( + on_message_callback: Callback, + on_status_change: Callback, + ) -> Self { + Self { + websocket_service: Rc::new(std::cell::RefCell::new(WebSocketService::new( + on_message_callback.clone(), + on_status_change.clone(), + ))), + http_service: HttpService::new(on_message_callback, on_status_change), + use_websocket: true, + } + } + + /// Connect to server using WebSocket or HTTP fallback + pub async fn connect(&mut self, url: &str) -> Result<(), Box> { + self.use_websocket = true; + + // Try WebSocket first + if let Ok(_) = self.websocket_service.borrow_mut().connect(url) { + log::info!("Connected via WebSocket"); + return Ok(()); + } + + // Fall back to HTTP + log::warn!("WebSocket failed, falling back to HTTP simulation"); + self.use_websocket = false; + self.http_service + .send(FromClient::Join { + group_name: std::sync::Arc::new("general".to_string()), + }) + .await?; + Ok(()) + } + + /// Send a message to the server + pub async fn send(&self, message: FromClient) -> Result<(), Box> { + if self.use_websocket { + self.websocket_service.borrow().send(message)?; + } else { + self.http_service.send(message).await?; + } + Ok(()) + } + + /// Disconnect from server + pub fn disconnect(&mut self) { + if self.use_websocket { + self.websocket_service.borrow_mut().disconnect(); + } + } + + /// Check if connected + pub fn is_connected(&self) -> bool { + if self.use_websocket { + self.websocket_service.borrow().is_connected() + } else { + true // HTTP simulation is always "connected" + } + } + + /// Set to HTTP mode (for development/testing) + pub fn set_http_mode(&mut self) { + self.use_websocket = false; + } +} + +/// Hook for using the connection service in components +#[hook] +pub fn use_connection_service( + on_message: Callback, + on_status_change: Callback, +) -> UseStateHandle { + let service = use_state(|| ConnectionService::new(on_message, on_status_change)); + service +} From e8b819dfa3471398a7c428d29e45483a345c5d2e Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:14:05 +0100 Subject: [PATCH 015/172] feat(services): add websocket module with public export - Create websocket module in services - Export all websocket functionalities publicly - Prepare for future websocket-related features --- web-ui/src/services/mod.rs | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 web-ui/src/services/mod.rs diff --git a/web-ui/src/services/mod.rs b/web-ui/src/services/mod.rs new file mode 100644 index 0000000..84c8405 --- /dev/null +++ b/web-ui/src/services/mod.rs @@ -0,0 +1,3 @@ +pub mod websocket; + +pub use websocket::*; From 1e3cfad89e14baaa7aad2ec075615c18da565c6b Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:14:15 +0100 Subject: [PATCH 016/172] style(styles): add global CSS variables and utility classes - Define color palette variables for dark and light themes - Add typography, spacing, border radius, shadow, and transition variables - Implement base styles for html and body with theme support - Create flexbox utility classes for layout control - Style scrollbars for better appearance - Add button and input component styles with hover and focus effects - Define text size and font weight utility classes - Include keyframe animations and corresponding utility classes for fade, slide, and pulse effects --- web-ui/src/styles/global.css | 317 +++++++++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 web-ui/src/styles/global.css diff --git a/web-ui/src/styles/global.css b/web-ui/src/styles/global.css new file mode 100644 index 0000000..8b2c96f --- /dev/null +++ b/web-ui/src/styles/global.css @@ -0,0 +1,317 @@ +/* Global Styles for Async Chat Web UI */ + +:root { + /* Color Palette - Discord inspired */ + --color-primary: #5865F2; + --color-primary-hover: #4752C4; + --color-secondary: #4752C4; + --color-success: #3BA55C; + --color-warning: #FAA61A; + --color-danger: #ED4245; + --color-info: #00AFF4; + + /* Dark Theme Colors */ + --color-bg-primary: #36393F; + --color-bg-secondary: #2F3136; + --color-bg-tertiary: #202225; + --color-bg-elevated: #4F545C; + --color-bg-input: #40444B; + + --color-text-primary: #DCDDDE; + --color-text-secondary: #B9BBBE; + --color-text-muted: #72767D; + --color-text-link: #00AFF4; + + --color-border-primary: #202225; + --color-border-secondary: #4F545C; + --color-border-input: #40444B; + + /* Light Theme Colors */ + --color-bg-primary-light: #FFFFFF; + --color-bg-secondary-light: #F2F3F5; + --color-bg-tertiary-light: #E3E5E8; + --color-bg-elevated-light: #FFFFFF; + --color-bg-input-light: #EBEDEF; + + --color-text-primary-light: #2E3338; + --color-text-secondary-light: #4F5660; + --color-text-muted-light: #747F8D; + --color-text-link-light: #0067C0; + + --color-border-primary-light: #E3E5E8; + --color-border-secondary-light: #C7C7C7; + --color-border-input-light: #B9BBBE; + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + --spacing-xxl: 48px; + + /* Typography */ + --font-size-xs: 12px; + --font-size-sm: 14px; + --font-size-md: 16px; + --font-size-lg: 18px; + --font-size-xl: 20px; + --font-size-xxl: 24px; + + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + /* Border Radius */ + --border-radius-sm: 4px; + --border-radius-md: 8px; + --border-radius-lg: 12px; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-normal: 200ms ease; + --transition-slow: 300ms ease; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + background-color: var(--color-bg-tertiary); + color: var(--color-text-primary); + font-size: var(--font-size-md); + line-height: 1.5; + overflow: hidden; +} + +/* Light Theme */ +body.light-theme { + --color-bg-primary: var(--color-bg-primary-light); + --color-bg-secondary: var(--color-bg-secondary-light); + --color-bg-tertiary: var(--color-bg-tertiary-light); + --color-bg-elevated: var(--color-bg-elevated-light); + --color-bg-input: var(--color-bg-input-light); + + --color-text-primary: var(--color-text-primary-light); + --color-text-secondary: var(--color-text-secondary-light); + --color-text-muted: var(--color-text-muted-light); + --color-text-link: var(--color-text-link-light); + + --color-border-primary: var(--color-border-primary-light); + --color-border-secondary: var(--color-border-secondary-light); + --color-border-input: var(--color-border-input-light); +} + +/* App Container */ +.app-container { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + background-color: var(--color-bg-primary); +} + +/* Main Layout */ +.chat-main { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--color-border-secondary); + border-radius: var(--border-radius-sm); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted); +} + +/* Utility Classes */ +.flex { + display: flex; +} + +.flex-column { + display: flex; + flex-direction: column; +} + +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +.flex-1 { + flex: 1; +} + +.justify-between { + justify-content: space-between; +} + +.items-center { + align-items: center; +} + +.gap-xs { gap: var(--spacing-xs); } +.gap-sm { gap: var(--spacing-sm); } +.gap-md { gap: var(--spacing-md); } +.gap-lg { gap: var(--spacing-lg); } +.gap-xl { gap: var(--spacing-xl); } + +.p-xs { padding: var(--spacing-xs); } +.p-sm { padding: var(--spacing-sm); } +.p-md { padding: var(--spacing-md); } +.p-lg { padding: var(--spacing-lg); } +.p-xl { padding: var(--spacing-xl); } + +.m-xs { margin: var(--spacing-xs); } +.m-sm { margin: var(--spacing-sm); } +.m-md { margin: var(--spacing-md); } +.m-lg { margin: var(--spacing-lg); } +.m-xl { margin: var(--spacing-xl); } + +.rounded-sm { border-radius: var(--border-radius-sm); } +.rounded-md { border-radius: var(--border-radius-md); } +.rounded-lg { border-radius: var(--border-radius-lg); } + +.text-primary { color: var(--color-text-primary); } +.text-secondary { color: var(--color-text-secondary); } +.text-muted { color: var(--color-text-muted); } +.text-link { color: var(--color-text-link); } + +.bg-primary { background-color: var(--color-bg-primary); } +.bg-secondary { background-color: var(--color-bg-secondary); } +.bg-elevated { background-color: var(--color-bg-elevated); } + +.border { border: 1px solid var(--color-border-primary); } +.border-secondary { border: 1px solid var(--color-border-secondary); } + +.cursor-pointer { cursor: pointer; } +.cursor-text { cursor: text; } + +.overflow-hidden { overflow: hidden; } +.overflow-auto { overflow: auto; } +.overflow-y-auto { overflow-y: auto; } + +.transition-fast { transition: all var(--transition-fast); } +.transition-normal { transition: all var(--transition-normal); } +.transition-slow { transition: all var(--transition-slow); } + +/* Button Styles */ +.btn { + padding: var(--spacing-sm) var(--spacing-md); + border: none; + border-radius: var(--border-radius-sm); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: var(--transition-fast); + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-xs); +} + +.btn-primary { + background-color: var(--color-primary); + color: white; +} + +.btn-primary:hover { + background-color: var(--color-primary-hover); +} + +.btn-secondary { + background-color: var(--color-bg-elevated); + color: var(--color-text-primary); + border: 1px solid var(--color-border-secondary); +} + +.btn-secondary:hover { + background-color: var(--color-bg-secondary); +} + +/* Input Styles */ +.input { + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border-input); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg-input); + color: var(--color-text-primary); + font-size: var(--font-size-md); + transition: var(--transition-fast); +} + +.input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(88, 101, 242, 0.2); +} + +.input::placeholder { + color: var(--color-text-muted); +} + +/* Typography */ +.text-xs { font-size: var(--font-size-xs); } +.text-sm { font-size: var(--font-size-sm); } +.text-md { font-size: var(--font-size-md); } +.text-lg { font-size: var(--font-size-lg); } +.text-xl { font-size: var(--font-size-xl); } +.text-xxl { font-size: var(--font-size-xxl); } + +.font-normal { font-weight: var(--font-weight-normal); } +.font-medium { font-weight: var(--font-weight-medium); } +.font-semibold { font-weight: var(--font-weight-semibold); } +.font-bold { font-weight: var(--font-weight-bold); } + +/* Animations */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideIn { + from { transform: translateY(-10px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.fade-in { animation: fadeIn var(--transition-normal); } +.slide-in { animation: slideIn var(--transition-normal); } +.pulse { animation: pulse 2s infinite; } \ No newline at end of file From b4c87ebc5a058a3d748c283151fa7ac4aa46e089 Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:14:25 +0100 Subject: [PATCH 017/172] feat(group-sidebar): add group sidebar component with join and select features - Implement GroupSidebar component displaying chat groups list - Add styles for sidebar layout, group items and join form - Support joining or creating groups via input form submission - Highlight currently selected group and handle group selection events - Show unread message count and member count badges for each group - Display empty state message and icon when no groups are available - Use stylist crate for scoped CSS styling integration with Yew framework --- web-ui/src/components/group_sidebar.rs | 279 +++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 web-ui/src/components/group_sidebar.rs diff --git a/web-ui/src/components/group_sidebar.rs b/web-ui/src/components/group_sidebar.rs new file mode 100644 index 0000000..b6e50f7 --- /dev/null +++ b/web-ui/src/components/group_sidebar.rs @@ -0,0 +1,279 @@ +use crate::types::ChatGroup; +use stylist::yew::styled_component; +use yew::prelude::*; + +#[derive(Properties, PartialEq)] +pub struct GroupSidebarProps { + pub groups: Vec, + pub current_group: Option, + pub on_group_select: Callback>, + pub on_join_group: Callback, +} + +#[styled_component(GroupSidebar)] +pub fn GroupSidebar(props: &GroupSidebarProps) -> Html { + let sidebar_style = stylist::Style::new( + r#" + .sidebar { + width: 240px; + background-color: var(--color-bg-secondary); + border-right: 1px solid var(--color-border-primary); + display: flex; + flex-direction: column; + height: 100%; + } + + .sidebar-header { + padding: var(--spacing-md); + border-bottom: 1px solid var(--color-border-primary); + } + + .sidebar-title { + font-size: var(--font-size-md); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin-bottom: var(--spacing-md); + } + + .join-group-form { + display: flex; + gap: var(--spacing-xs); + } + + .join-input { + flex: 1; + padding: var(--spacing-xs) var(--spacing-sm); + border: 1px solid var(--color-border-input); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg-input); + color: var(--color-text-primary); + font-size: var(--font-size-sm); + } + + .join-input:focus { + outline: none; + border-color: var(--color-primary); + } + + .join-button { + padding: var(--spacing-xs) var(--spacing-sm); + background-color: var(--color-primary); + color: white; + border: none; + border-radius: var(--border-radius-sm); + font-size: var(--font-size-sm); + cursor: pointer; + transition: var(--transition-fast); + } + + .join-button:hover { + background-color: var(--color-primary-hover); + } + + .join-button:disabled { + background-color: var(--color-text-muted); + cursor: not-allowed; + } + + .groups-list { + flex: 1; + overflow-y: auto; + padding: var(--spacing-sm); + } + + .group-item { + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--border-radius-sm); + cursor: pointer; + transition: var(--transition-fast); + margin-bottom: var(--spacing-xs); + display: flex; + align-items: center; + justify-content: space-between; + } + + .group-item:hover { + background-color: var(--color-bg-elevated); + } + + .group-item.active { + background-color: var(--color-primary); + color: white; + } + + .group-info { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + } + + .group-name { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: inherit; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .group-meta { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + margin-top: 2px; + } + + .group-item.active .group-meta { + color: rgba(255, 255, 255, 0.7); + } + + .group-badges { + display: flex; + align-items: center; + gap: var(--spacing-xs); + } + + .member-count { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + } + + .group-item.active .member-count { + color: rgba(255, 255, 255, 0.7); + } + + .unread-badge { + background-color: var(--color-danger); + color: white; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + padding: 2px 6px; + border-radius: 10px; + min-width: 18px; + text-align: center; + } + + .empty-state { + padding: var(--spacing-lg); + text-align: center; + color: var(--color-text-muted); + } + + .empty-icon { + font-size: var(--font-size-xl); + margin-bottom: var(--spacing-sm); + opacity: 0.5; + } + + .empty-text { + font-size: var(--font-size-sm); + } + "#, + ) + .expect("Failed to create sidebar styles"); + + let groups = props.groups.clone(); + let current_group = props.current_group.clone(); + let on_group_select = props.on_group_select.clone(); + let on_join_group = props.on_join_group.clone(); + + let on_input_change = Callback::from(move |e: Event| { + let input: web_sys::HtmlInputElement = e.target_unchecked_into(); + // Handle input change if needed + }); + + let on_submit = Callback::from(move |e: SubmitEvent| { + e.prevent_default(); + let input = web_sys::window() + .unwrap() + .document() + .unwrap() + .get_element_by_id("join-group-input") + .unwrap() + .dyn_into::() + .unwrap(); + + let group_name = input.value().trim().to_string(); + if !group_name.is_empty() { + on_join_group.emit(group_name); + input.set_value(""); + } + }); + + html! { + + } +} From ae24b435d74f4c11ea9cc4925551f6958fe03e0a Mon Sep 17 00:00:00 2001 From: chojuninengu Date: Wed, 14 Jan 2026 16:15:32 +0100 Subject: [PATCH 018/172] feat(message-input): add new message input component with styling and validation - Implement message input with textarea, send button, attachment and emoji buttons - Add disabled state handling based on props and group selection - Implement message send callback with enter key handling and form submission - Add character count display with warning and error color coding for limits - Create scoped CSS styles for input, buttons, and layout using stylist crate - Support placeholder text dynamic to current group selection - Prevent sending empty messages and limit message length to 2000 characters --- web-ui/src/components/message_input.rs | 288 +++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 web-ui/src/components/message_input.rs diff --git a/web-ui/src/components/message_input.rs b/web-ui/src/components/message_input.rs new file mode 100644 index 0000000..6f4a453 --- /dev/null +++ b/web-ui/src/components/message_input.rs @@ -0,0 +1,288 @@ +use stylist::yew::styled_component; +use web_sys::HtmlInputElement; +use yew::prelude::*; + +#[derive(Properties, PartialEq)] +pub struct MessageInputProps { + pub current_group: Option, + pub current_user: String, + pub on_send_message: Callback<(String, String)>, + pub disabled: bool, +} + +#[styled_component(MessageInput)] +pub fn MessageInput(props: &MessageInputProps) -> Html { + let message_text = use_state(|| String::new()); + let is_typing = use_state(|| false); + + let on_input_change = { + let message_text = message_text.clone(); + Callback::from(move |e: Event| { + let input: HtmlInputElement = e.target_unchecked_into(); + let value = input.value(); + message_text.set(value); + }) + }; + + let on_submit = { + let message_text = message_text.clone(); + let current_group = props.current_group.clone(); + let on_send_message = props.on_send_message.clone(); + + Callback::from(move |e: SubmitEvent| { + e.prevent_default(); + + let message = (*message_text).clone().trim().to_string(); + + if !message.is_empty() { + if let Some(group_name) = ¤t_group { + on_send_message.emit((group_name.clone(), message)); + message_text.set(String::new()); + } + } + }) + }; + + let on_keydown = { + let on_submit = on_submit.clone(); + Callback::from(move |e: KeyboardEvent| { + if e.key() == "Enter" && !e.shift_key() { + e.prevent_default(); + on_submit.emit(e.into()); + } + }) + }; + + let is_disabled = props.disabled || props.current_group.is_none(); + let placeholder = match props.current_group { + Some(ref group) => format!("Message #{}", group), + None => "Select a group to start messaging...".to_string(), + }; + + let message_input_style = stylist::Style::new( + r#" + .message-input-container { + background-color: var(--color-bg-secondary); + border-top: 1px solid var(--color-border-primary); + padding: var(--spacing-md); + } + + .input-wrapper { + display: flex; + align-items: flex-end; + gap: var(--spacing-sm); + max-width: 100%; + } + + .message-textarea { + flex: 1; + background-color: var(--color-bg-input); + border: 1px solid var(--color-border-input); + border-radius: var(--border-radius-md); + padding: var(--spacing-sm) var(--spacing-md); + color: var(--color-text-primary); + font-size: var(--font-size-md); + font-family: inherit; + resize: none; + min-height: 44px; + max-height: 120px; + line-height: 1.4; + transition: var(--transition-fast); + overflow-y: auto; + } + + .message-textarea:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(88, 101, 242, 0.2); + } + + .message-textarea:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .message-textarea::placeholder { + color: var(--color-text-muted); + } + + .send-button { + background-color: var(--color-primary); + color: white; + border: none; + border-radius: var(--border-radius-sm); + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: var(--transition-fast); + min-width: 60px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-xs); + } + + .send-button:hover:not(:disabled) { + background-color: var(--color-primary-hover); + } + + .send-button:disabled { + background-color: var(--color-text-muted); + cursor: not-allowed; + opacity: 0.6; + } + + .send-icon { + font-size: var(--font-size-md); + } + + .attachment-button { + background: none; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + padding: var(--spacing-sm); + border-radius: var(--border-radius-sm); + transition: var(--transition-fast); + height: 44px; + width: 44px; + display: flex; + align-items: center; + justify-content: center; + } + + .attachment-button:hover:not(:disabled) { + background-color: var(--color-bg-elevated); + color: var(--color-text-primary); + } + + .attachment-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .attachment-icon { + font-size: var(--font-size-lg); + } + + .character-count { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + margin-top: var(--spacing-xs); + text-align: right; + } + + .character-count.warning { + color: var(--color-warning); + } + + .character-count.error { + color: var(--color-danger); + } + + .emoji-button { + background: none; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + padding: var(--spacing-sm); + border-radius: var(--border-radius-sm); + transition: var(--transition-fast); + height: 44px; + width: 44px; + display: flex; + align-items: center; + justify-content: center; + } + + .emoji-button:hover:not(:disabled) { + background-color: var(--color-bg-elevated); + color: var(--color-text-primary); + } + + .emoji-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .emoji-icon { + font-size: var(--font-size-lg); + } + "#, + ) + .expect("Failed to create message input styles"); + + let character_count = (*message_text).len(); + let max_characters = 2000; + let character_class = if character_count > max_characters * 9 / 10 { + "character-count error" + } else if character_count > max_characters * 8 / 10 { + "character-count warning" + } else { + "character-count" + }; + + let can_send = + !is_disabled && !(*message_text).trim().is_empty() && character_count <= max_characters; + + html! { +
+
+
+
+ + +