add file browser component and widget
This commit is contained in:
48
components/Cargo.toml
Normal file
48
components/Cargo.toml
Normal file
@@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "components"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "components"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# Use workspace dependencies
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
futures-channel = { workspace = true }
|
||||
|
||||
# WASM-specific dependencies
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = { workspace = true }
|
||||
wasm-bindgen-futures = { workspace = true }
|
||||
yew = { workspace = true }
|
||||
gloo = { workspace = true }
|
||||
gloo-timers = { workspace = true }
|
||||
web-sys = { workspace = true }
|
||||
js-sys = { workspace = true }
|
||||
serde-wasm-bindgen = "0.6"
|
||||
hex = "0.4"
|
||||
k256 = { version = "0.13", features = ["ecdsa", "sha256"] }
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
gloo-net = "0.5"
|
||||
reqwest = { workspace = true }
|
||||
|
||||
# Native-specific dependencies
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
hero_websocket_client = { path = "../../hero/interfaces/websocket/client", default-features = false, features = [] }
|
||||
tokio = { workspace = true, features = ["rt", "macros", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
wasm-bindgen-test = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
crypto = ["hero_websocket_client/crypto"]
|
||||
wasm-compatible = [] # For WASM builds without crypto to avoid wasm-opt issues
|
192
components/src/auth.rs
Normal file
192
components/src/auth.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
//! Authentication configuration for WebSocket connections
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::error::{WsError, WsResult};
|
||||
|
||||
/// Authentication configuration for WebSocket connections
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AuthConfig {
|
||||
/// Private key for secp256k1 authentication (hex format)
|
||||
private_key: String,
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
/// Create a new authentication configuration with a private key
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `private_key` - The private key in hex format for secp256k1 authentication
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use framework::AuthConfig;
|
||||
///
|
||||
/// let auth = AuthConfig::new("your_private_key_hex".to_string());
|
||||
/// ```
|
||||
pub fn new(private_key: String) -> Self {
|
||||
Self { private_key }
|
||||
}
|
||||
|
||||
/// Create authentication configuration from environment variable
|
||||
///
|
||||
/// Looks for the private key in the `WS_PRIVATE_KEY` environment variable
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use framework::AuthConfig;
|
||||
///
|
||||
/// // Set environment variable: WS_PRIVATE_KEY=your_private_key_hex
|
||||
/// let auth = AuthConfig::from_env().expect("WS_PRIVATE_KEY not set");
|
||||
/// ```
|
||||
pub fn from_env() -> WsResult<Self> {
|
||||
let private_key = std::env::var("WS_PRIVATE_KEY")
|
||||
.map_err(|_| WsError::auth("WS_PRIVATE_KEY environment variable not set"))?;
|
||||
|
||||
if private_key.is_empty() {
|
||||
return Err(WsError::auth("WS_PRIVATE_KEY environment variable is empty"));
|
||||
}
|
||||
|
||||
Ok(Self::new(private_key))
|
||||
}
|
||||
|
||||
/// Get the private key
|
||||
pub fn private_key(&self) -> &str {
|
||||
&self.private_key
|
||||
}
|
||||
|
||||
/// Validate the private key format
|
||||
///
|
||||
/// Checks if the private key is a valid hex string of the correct length
|
||||
pub fn validate(&self) -> WsResult<()> {
|
||||
if self.private_key.is_empty() {
|
||||
return Err(WsError::auth("Private key cannot be empty"));
|
||||
}
|
||||
|
||||
// Check if it's a valid hex string
|
||||
if !self.private_key.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(WsError::auth("Private key must be a valid hex string"));
|
||||
}
|
||||
|
||||
// secp256k1 private keys are 32 bytes = 64 hex characters
|
||||
if self.private_key.len() != 64 {
|
||||
return Err(WsError::auth(
|
||||
"Private key must be 64 hex characters (32 bytes) for secp256k1"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a CircleWsClient with this authentication configuration
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ws_url` - The WebSocket URL to connect to
|
||||
///
|
||||
/// # Returns
|
||||
/// A configured CircleWsClient ready for connection and authentication
|
||||
#[cfg(feature = "crypto")]
|
||||
pub fn create_client(&self, ws_url: String) -> circle_client_ws::CircleWsClient {
|
||||
circle_client_ws::CircleWsClientBuilder::new(ws_url)
|
||||
.with_keypair(self.private_key.clone())
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Create a CircleWsClient without authentication (WASM-compatible mode)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ws_url` - The WebSocket URL to connect to
|
||||
///
|
||||
/// # Returns
|
||||
/// A basic CircleWsClient without authentication
|
||||
#[cfg(all(not(feature = "crypto"), not(target_arch = "wasm32")))]
|
||||
pub fn create_client(&self, ws_url: String) -> hero_websocket_client::CircleWsClient {
|
||||
hero_websocket_client::CircleWsClientBuilder::new(ws_url).build()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder pattern for AuthConfig
|
||||
pub struct AuthConfigBuilder {
|
||||
private_key: Option<String>,
|
||||
}
|
||||
|
||||
impl AuthConfigBuilder {
|
||||
/// Create a new AuthConfig builder
|
||||
pub fn new() -> Self {
|
||||
Self { private_key: None }
|
||||
}
|
||||
|
||||
/// Set the private key
|
||||
pub fn private_key<S: Into<String>>(mut self, private_key: S) -> Self {
|
||||
self.private_key = Some(private_key.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Try to load private key from environment
|
||||
pub fn from_env(mut self) -> WsResult<Self> {
|
||||
let private_key = std::env::var("WS_PRIVATE_KEY")
|
||||
.map_err(|_| WsError::auth("WS_PRIVATE_KEY environment variable not set"))?;
|
||||
self.private_key = Some(private_key);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Build the AuthConfig
|
||||
pub fn build(self) -> WsResult<AuthConfig> {
|
||||
let private_key = self.private_key
|
||||
.ok_or_else(|| WsError::auth("Private key is required"))?;
|
||||
|
||||
let config = AuthConfig::new(private_key);
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AuthConfigBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_auth_config_creation() {
|
||||
let private_key = "a".repeat(64); // 64 hex characters
|
||||
let auth = AuthConfig::new(private_key.clone());
|
||||
assert_eq!(auth.private_key(), &private_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_config_validation() {
|
||||
// Valid private key
|
||||
let valid_key = "a".repeat(64);
|
||||
let auth = AuthConfig::new(valid_key);
|
||||
assert!(auth.validate().is_ok());
|
||||
|
||||
// Invalid length
|
||||
let invalid_key = "a".repeat(32);
|
||||
let auth = AuthConfig::new(invalid_key);
|
||||
assert!(auth.validate().is_err());
|
||||
|
||||
// Invalid hex characters
|
||||
let invalid_hex = "g".repeat(64);
|
||||
let auth = AuthConfig::new(invalid_hex);
|
||||
assert!(auth.validate().is_err());
|
||||
|
||||
// Empty key
|
||||
let empty_key = String::new();
|
||||
let auth = AuthConfig::new(empty_key);
|
||||
assert!(auth.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_config_builder() {
|
||||
let private_key = "a".repeat(64);
|
||||
let auth = AuthConfigBuilder::new()
|
||||
.private_key(private_key.clone())
|
||||
.build()
|
||||
.expect("Should build successfully");
|
||||
|
||||
assert_eq!(auth.private_key(), &private_key);
|
||||
}
|
||||
}
|
806
components/src/browser_auth.rs
Normal file
806
components/src/browser_auth.rs
Normal file
@@ -0,0 +1,806 @@
|
||||
//! Browser-based authentication with encrypted private key storage
|
||||
//!
|
||||
//! This module provides authentication functionality for web applications where:
|
||||
//! - Users have a password that acts as a symmetric key
|
||||
//! - Private keys are encrypted and stored in browser storage
|
||||
//! - Users can register multiple private keys and choose which one to use for login
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use yew::prelude::*;
|
||||
use web_sys::Storage;
|
||||
use k256::{SecretKey, elliptic_curve::sec1::ToEncodedPoint};
|
||||
use getrandom::getrandom;
|
||||
// Note: error module not available in WASM builds
|
||||
// use crate::error::{WsError, WsResult};
|
||||
|
||||
// Define local error types for WASM builds
|
||||
pub type WsResult<T> = Result<T, String>;
|
||||
|
||||
const STORAGE_KEY: &str = "herocode_auth_keys";
|
||||
|
||||
/// Represents an encrypted private key entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EncryptedKeyEntry {
|
||||
/// Display name for this key
|
||||
pub name: String,
|
||||
/// Encrypted private key data
|
||||
pub encrypted_key: String,
|
||||
/// Salt used for encryption
|
||||
pub salt: String,
|
||||
/// Timestamp when this key was created
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// Authentication state for the current session
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthState {
|
||||
/// User is not authenticated
|
||||
Unauthenticated,
|
||||
/// User is authenticated with a specific key
|
||||
Authenticated {
|
||||
key_name: String,
|
||||
private_key: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Browser authentication manager
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BrowserAuthManager {
|
||||
/// Current authentication state
|
||||
state: AuthState,
|
||||
/// Available encrypted keys
|
||||
encrypted_keys: HashMap<String, EncryptedKeyEntry>,
|
||||
}
|
||||
|
||||
impl Default for BrowserAuthManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserAuthManager {
|
||||
/// Create a new browser authentication manager
|
||||
pub fn new() -> Self {
|
||||
let mut manager = Self {
|
||||
state: AuthState::Unauthenticated,
|
||||
encrypted_keys: HashMap::new(),
|
||||
};
|
||||
|
||||
// Load existing keys from browser storage
|
||||
if let Err(e) = manager.load_keys() {
|
||||
log::warn!("Failed to load keys from storage: {:?}", e);
|
||||
}
|
||||
|
||||
manager
|
||||
}
|
||||
|
||||
/// Get the current authentication state
|
||||
pub fn state(&self) -> &AuthState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Check if user is currently authenticated
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
matches!(self.state, AuthState::Authenticated { .. })
|
||||
}
|
||||
|
||||
/// Get the current private key if authenticated
|
||||
pub fn current_private_key(&self) -> Option<&str> {
|
||||
match &self.state {
|
||||
AuthState::Authenticated { private_key, .. } => Some(private_key),
|
||||
AuthState::Unauthenticated => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current key name if authenticated
|
||||
pub fn current_key_name(&self) -> Option<&str> {
|
||||
match &self.state {
|
||||
AuthState::Authenticated { key_name, .. } => Some(key_name),
|
||||
AuthState::Unauthenticated => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get list of available key names
|
||||
pub fn available_keys(&self) -> Vec<String> {
|
||||
self.encrypted_keys.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get list of registered key names (alias for available_keys)
|
||||
pub fn get_registered_keys(&self) -> WsResult<Vec<String>> {
|
||||
Ok(self.available_keys())
|
||||
}
|
||||
|
||||
/// Get public key for a given private key (if authenticated with that key)
|
||||
pub fn get_public_key(&self, key_name: &str) -> WsResult<String> {
|
||||
match &self.state {
|
||||
AuthState::Authenticated { key_name: current_key, private_key } if current_key == key_name => {
|
||||
// Convert private key to public key
|
||||
let private_key_bytes = hex::decode(private_key)
|
||||
.map_err(|_| "Invalid private key hex")?;
|
||||
|
||||
if private_key_bytes.len() != 32 {
|
||||
return Err("Invalid private key length".to_string());
|
||||
}
|
||||
|
||||
let mut key_array = [0u8; 32];
|
||||
key_array.copy_from_slice(&private_key_bytes);
|
||||
|
||||
let secret_key = SecretKey::from_bytes(&key_array.into())
|
||||
.map_err(|e| format!("Failed to create secret key: {}", e))?;
|
||||
|
||||
let public_key = secret_key.public_key();
|
||||
Ok(hex::encode(public_key.to_encoded_point(false).as_bytes()))
|
||||
}
|
||||
_ => Err("Key not currently authenticated".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new private key with encryption
|
||||
pub fn register_key(&mut self, name: String, private_key: String, password: String) -> WsResult<()> {
|
||||
// Validate private key format
|
||||
if !self.validate_private_key(&private_key)? {
|
||||
return Err("Invalid private key format".to_string());
|
||||
}
|
||||
|
||||
// Generate a random salt
|
||||
let salt = self.generate_salt();
|
||||
|
||||
// Encrypt the private key
|
||||
let encrypted_key = self.encrypt_key(&private_key, &password, &salt)?;
|
||||
|
||||
let entry = EncryptedKeyEntry {
|
||||
name: name.clone(),
|
||||
encrypted_key,
|
||||
salt,
|
||||
created_at: js_sys::Date::now() as i64,
|
||||
};
|
||||
|
||||
self.encrypted_keys.insert(name, entry);
|
||||
self.save_keys()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attempt to login with a specific key and password
|
||||
pub fn login(&mut self, key_name: String, password: String) -> WsResult<()> {
|
||||
let entry = self.encrypted_keys.get(&key_name)
|
||||
.ok_or_else(|| "Key not found".to_string())?;
|
||||
|
||||
// Decrypt the private key
|
||||
let private_key = self.decrypt_key(&entry.encrypted_key, &password, &entry.salt)?;
|
||||
|
||||
// Validate the decrypted key
|
||||
if !self.validate_private_key(&private_key)? {
|
||||
return Err("Failed to decrypt key or invalid key format".to_string());
|
||||
}
|
||||
|
||||
self.state = AuthState::Authenticated {
|
||||
key_name,
|
||||
private_key,
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Logout the current user
|
||||
pub fn logout(&mut self) {
|
||||
self.state = AuthState::Unauthenticated;
|
||||
}
|
||||
|
||||
/// Remove a registered key
|
||||
pub fn remove_key(&mut self, key_name: &str) -> WsResult<()> {
|
||||
if self.encrypted_keys.remove(key_name).is_none() {
|
||||
return Err("Key not found".to_string());
|
||||
}
|
||||
|
||||
// If we're currently authenticated with this key, logout
|
||||
if let AuthState::Authenticated { key_name: current_key, .. } = &self.state {
|
||||
if current_key == key_name {
|
||||
self.logout();
|
||||
}
|
||||
}
|
||||
|
||||
self.save_keys()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a new secp256k1 private key using k256
|
||||
pub fn generate_key() -> WsResult<String> {
|
||||
let mut rng_bytes = [0u8; 32];
|
||||
|
||||
// Use getrandom to get cryptographically secure random bytes
|
||||
getrandom(&mut rng_bytes)
|
||||
.map_err(|e| format!("Failed to generate random bytes: {}", e))?;
|
||||
|
||||
let secret_key = SecretKey::from_bytes(&rng_bytes.into())
|
||||
.map_err(|e| format!("Failed to create secret key: {}", e))?;
|
||||
|
||||
Ok(hex::encode(secret_key.to_bytes()))
|
||||
}
|
||||
|
||||
/// Load encrypted keys from browser storage
|
||||
fn load_keys(&mut self) -> WsResult<()> {
|
||||
let storage = self.get_local_storage()?;
|
||||
|
||||
if let Ok(Some(data)) = storage.get_item(STORAGE_KEY) {
|
||||
if !data.is_empty() {
|
||||
let keys: HashMap<String, EncryptedKeyEntry> = serde_json::from_str(&data)
|
||||
.map_err(|e| format!("Failed to parse stored keys: {}", e))?;
|
||||
self.encrypted_keys = keys;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save encrypted keys to browser storage
|
||||
fn save_keys(&self) -> WsResult<()> {
|
||||
let storage = self.get_local_storage()?;
|
||||
let data = serde_json::to_string(&self.encrypted_keys)
|
||||
.map_err(|e| format!("Failed to serialize keys: {}", e))?;
|
||||
|
||||
storage.set_item(STORAGE_KEY, &data)
|
||||
.map_err(|_| "Failed to save keys to storage".to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get browser local storage
|
||||
fn get_local_storage(&self) -> WsResult<Storage> {
|
||||
let window = web_sys::window()
|
||||
.ok_or_else(|| "No window object available".to_string())?;
|
||||
|
||||
window.local_storage()
|
||||
.map_err(|_| "Failed to access local storage".to_string())?
|
||||
.ok_or_else(|| "Local storage not available".to_string())
|
||||
}
|
||||
|
||||
/// Validate private key format (secp256k1)
|
||||
fn validate_private_key(&self, private_key: &str) -> WsResult<bool> {
|
||||
if private_key.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Check if it's a valid hex string
|
||||
if !private_key.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// secp256k1 private keys are 32 bytes = 64 hex characters
|
||||
Ok(private_key.len() == 64)
|
||||
}
|
||||
|
||||
/// Generate a random salt for encryption
|
||||
fn generate_salt(&self) -> String {
|
||||
// Generate 32 random bytes as hex string
|
||||
let mut salt = String::new();
|
||||
for _ in 0..32 {
|
||||
salt.push_str(&format!("{:02x}", (js_sys::Math::random() * 256.0) as u8));
|
||||
}
|
||||
salt
|
||||
}
|
||||
|
||||
/// Encrypt a private key using password and salt
|
||||
fn encrypt_key(&self, private_key: &str, password: &str, salt: &str) -> WsResult<String> {
|
||||
// Simple XOR encryption for now - in production, use proper encryption
|
||||
// This is a placeholder implementation
|
||||
let key_bytes = hex::decode(private_key)
|
||||
.map_err(|_| "Invalid private key hex".to_string())?;
|
||||
let salt_bytes = hex::decode(salt)
|
||||
.map_err(|_| "Invalid salt hex".to_string())?;
|
||||
|
||||
// Create a key from password and salt using a simple hash
|
||||
let mut password_key = Vec::new();
|
||||
let password_bytes = password.as_bytes();
|
||||
for i in 0..32 {
|
||||
let p_byte = password_bytes.get(i % password_bytes.len()).unwrap_or(&0);
|
||||
let s_byte = salt_bytes.get(i).unwrap_or(&0);
|
||||
password_key.push(p_byte ^ s_byte);
|
||||
}
|
||||
|
||||
// XOR encrypt
|
||||
let mut encrypted = Vec::new();
|
||||
for (i, &byte) in key_bytes.iter().enumerate() {
|
||||
encrypted.push(byte ^ password_key[i % password_key.len()]);
|
||||
}
|
||||
|
||||
Ok(hex::encode(encrypted))
|
||||
}
|
||||
/// Decrypt a private key using password and salt
|
||||
fn decrypt_key(&self, encrypted_key: &str, password: &str, salt: &str) -> WsResult<String> {
|
||||
// Simple XOR decryption - matches encrypt_key implementation
|
||||
let encrypted_bytes = hex::decode(encrypted_key)
|
||||
.map_err(|_| "Invalid base64 in stored keys".to_string())?;
|
||||
let salt_bytes = hex::decode(salt)
|
||||
.map_err(|_| "Invalid salt hex".to_string())?;
|
||||
|
||||
// Create the same key from password and salt
|
||||
let mut password_key = Vec::new();
|
||||
let password_bytes = password.as_bytes();
|
||||
for i in 0..32 {
|
||||
let p_byte = password_bytes.get(i % password_bytes.len()).unwrap_or(&0);
|
||||
let s_byte = salt_bytes.get(i).unwrap_or(&0);
|
||||
password_key.push(p_byte ^ s_byte);
|
||||
}
|
||||
|
||||
// XOR decrypt
|
||||
let mut decrypted = Vec::new();
|
||||
for (i, &byte) in encrypted_bytes.iter().enumerate() {
|
||||
decrypted.push(byte ^ password_key[i % password_key.len()]);
|
||||
}
|
||||
|
||||
Ok(hex::encode(decrypted))
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication context for Yew components
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AuthContext {
|
||||
pub manager: BrowserAuthManager,
|
||||
}
|
||||
|
||||
impl AuthContext {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
manager: BrowserAuthManager::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Messages for authentication component
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AuthMsg {
|
||||
Login(String, String), // key_name, password
|
||||
Logout,
|
||||
RegisterKey(String, String, String), // name, private_key, password
|
||||
RemoveKey(String), // key_name
|
||||
GenerateKey(String, String), // name, password
|
||||
ShowLoginForm,
|
||||
ShowRegisterForm,
|
||||
ShowGenerateKeyForm,
|
||||
HideForm,
|
||||
ToggleDropdown,
|
||||
}
|
||||
|
||||
/// Authentication component state
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthFormState {
|
||||
Hidden,
|
||||
Login,
|
||||
Register,
|
||||
GenerateKey,
|
||||
}
|
||||
|
||||
/// Properties for the authentication component
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct AuthComponentProps {
|
||||
#[prop_or_default]
|
||||
pub on_auth_change: Callback<AuthState>,
|
||||
}
|
||||
|
||||
/// Main authentication component for the header
|
||||
pub struct AuthComponent {
|
||||
manager: BrowserAuthManager,
|
||||
form_state: AuthFormState,
|
||||
login_key_name: String,
|
||||
login_password: String,
|
||||
register_name: String,
|
||||
register_key: String,
|
||||
register_password: String,
|
||||
error_message: Option<String>,
|
||||
dropdown_open: bool,
|
||||
}
|
||||
|
||||
impl Component for AuthComponent {
|
||||
type Message = AuthMsg;
|
||||
type Properties = AuthComponentProps;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
manager: BrowserAuthManager::new(),
|
||||
form_state: AuthFormState::Hidden,
|
||||
login_key_name: String::new(),
|
||||
login_password: String::new(),
|
||||
register_name: String::new(),
|
||||
register_key: String::new(),
|
||||
register_password: String::new(),
|
||||
error_message: None,
|
||||
dropdown_open: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
AuthMsg::Login(key_name, password) => {
|
||||
match self.manager.login(key_name, password) {
|
||||
Ok(()) => {
|
||||
self.form_state = AuthFormState::Hidden;
|
||||
self.error_message = None;
|
||||
ctx.props().on_auth_change.emit(self.manager.state().clone());
|
||||
}
|
||||
Err(e) => {
|
||||
self.error_message = Some(format!("Login failed: {}", e));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
AuthMsg::Logout => {
|
||||
self.manager.logout();
|
||||
ctx.props().on_auth_change.emit(self.manager.state().clone());
|
||||
true
|
||||
}
|
||||
AuthMsg::RegisterKey(name, private_key, password) => {
|
||||
match self.manager.register_key(name, private_key, password) {
|
||||
Ok(()) => {
|
||||
self.form_state = AuthFormState::Hidden;
|
||||
self.error_message = None;
|
||||
self.register_name.clear();
|
||||
self.register_key.clear();
|
||||
self.register_password.clear();
|
||||
}
|
||||
Err(e) => {
|
||||
self.error_message = Some(format!("Registration failed: {}", e));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
AuthMsg::RemoveKey(key_name) => {
|
||||
if let Err(e) = self.manager.remove_key(&key_name) {
|
||||
self.error_message = Some(format!("Failed to remove key: {}", e));
|
||||
} else {
|
||||
ctx.props().on_auth_change.emit(self.manager.state().clone());
|
||||
}
|
||||
true
|
||||
}
|
||||
AuthMsg::ShowLoginForm => {
|
||||
self.form_state = AuthFormState::Login;
|
||||
self.error_message = None;
|
||||
self.dropdown_open = false;
|
||||
true
|
||||
}
|
||||
AuthMsg::ShowRegisterForm => {
|
||||
self.form_state = AuthFormState::Register;
|
||||
self.error_message = None;
|
||||
self.dropdown_open = false;
|
||||
true
|
||||
}
|
||||
AuthMsg::GenerateKey(name, password) => {
|
||||
match BrowserAuthManager::generate_key() {
|
||||
Ok(private_key) => {
|
||||
match self.manager.register_key(name, private_key, password) {
|
||||
Ok(()) => {
|
||||
self.form_state = AuthFormState::Hidden;
|
||||
self.error_message = None;
|
||||
self.register_name.clear();
|
||||
self.register_password.clear();
|
||||
ctx.props().on_auth_change.emit(self.manager.state().clone());
|
||||
}
|
||||
Err(e) => {
|
||||
self.error_message = Some(format!("Failed to register generated key: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.error_message = Some(format!("Failed to generate key: {}", e));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
AuthMsg::ShowGenerateKeyForm => {
|
||||
self.form_state = AuthFormState::GenerateKey;
|
||||
self.error_message = None;
|
||||
self.dropdown_open = false;
|
||||
true
|
||||
}
|
||||
AuthMsg::HideForm => {
|
||||
self.form_state = AuthFormState::Hidden;
|
||||
self.error_message = None;
|
||||
true
|
||||
}
|
||||
AuthMsg::ToggleDropdown => {
|
||||
self.dropdown_open = !self.dropdown_open;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div class="auth-component">
|
||||
{self.render_auth_button(ctx)}
|
||||
{self.render_auth_form(ctx)}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthComponent {
|
||||
fn render_auth_button(&self, ctx: &Context<Self>) -> Html {
|
||||
match self.manager.state() {
|
||||
AuthState::Unauthenticated => {
|
||||
let dropdown_class = if self.dropdown_open {
|
||||
"dropdown-menu dropdown-menu-end show"
|
||||
} else {
|
||||
"dropdown-menu dropdown-menu-end"
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-light dropdown-toggle" type="button"
|
||||
onclick={ctx.link().callback(|_| AuthMsg::ToggleDropdown)}>
|
||||
<i class="bi bi-person-circle me-1"></i>{"Login"}
|
||||
</button>
|
||||
<ul class={dropdown_class}>
|
||||
<li>
|
||||
<button class="dropdown-item" onclick={ctx.link().callback(|_| {
|
||||
AuthMsg::ShowLoginForm
|
||||
})}>
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>{"Login"}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" onclick={ctx.link().callback(|_| {
|
||||
AuthMsg::ShowRegisterForm
|
||||
})}>
|
||||
<i class="bi bi-person-plus me-2"></i>{"Register Key"}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" onclick={ctx.link().callback(|_| {
|
||||
AuthMsg::ShowGenerateKeyForm
|
||||
})}>
|
||||
<i class="bi bi-key me-2"></i>{"Generate Key"}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
AuthState::Authenticated { key_name, .. } => {
|
||||
let dropdown_class = if self.dropdown_open {
|
||||
"dropdown-menu dropdown-menu-end show"
|
||||
} else {
|
||||
"dropdown-menu dropdown-menu-end"
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-success dropdown-toggle" type="button"
|
||||
onclick={ctx.link().callback(|_| AuthMsg::ToggleDropdown)}>
|
||||
<i class="bi bi-person-check-fill me-1"></i>{key_name}
|
||||
</button>
|
||||
<ul class={dropdown_class}>
|
||||
<li>
|
||||
<button class="dropdown-item" onclick={ctx.link().callback(|_| {
|
||||
AuthMsg::ShowRegisterForm
|
||||
})}>
|
||||
<i class="bi bi-person-plus me-2"></i>{"Register New Key"}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" onclick={ctx.link().callback(|_| {
|
||||
AuthMsg::ShowGenerateKeyForm
|
||||
})}>
|
||||
<i class="bi bi-key me-2"></i>{"Generate Key"}
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"/></li>
|
||||
<li>
|
||||
<button class="dropdown-item text-danger" onclick={ctx.link().callback(|_| {
|
||||
AuthMsg::Logout
|
||||
})}>
|
||||
<i class="bi bi-box-arrow-right me-2"></i>{"Logout"}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_auth_form(&self, ctx: &Context<Self>) -> Html {
|
||||
match self.form_state {
|
||||
AuthFormState::Hidden => html! {},
|
||||
AuthFormState::Login => self.render_login_form(ctx),
|
||||
AuthFormState::Register => self.render_register_form(ctx),
|
||||
AuthFormState::GenerateKey => self.render_generate_key_form(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_login_form(&self, ctx: &Context<Self>) -> Html {
|
||||
let available_keys = self.manager.available_keys();
|
||||
|
||||
let on_submit = {
|
||||
let link = ctx.link().clone();
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
// Get form data directly from the form
|
||||
let form = e.target_dyn_into::<web_sys::HtmlFormElement>().unwrap();
|
||||
let form_data = web_sys::FormData::new_with_form(&form).unwrap();
|
||||
|
||||
let key_name = form_data.get("keySelect").as_string().unwrap_or_default();
|
||||
let password = form_data.get("password").as_string().unwrap_or_default();
|
||||
|
||||
if !key_name.is_empty() && !password.is_empty() {
|
||||
link.send_message(AuthMsg::Login(key_name, password));
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{"Login"}</h5>
|
||||
<button type="button" class="btn-close" onclick={ctx.link().callback(|_| AuthMsg::HideForm)}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{if let Some(error) = &self.error_message {
|
||||
html! {
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="mb-3">
|
||||
<label for="keySelect" class="form-label">{"Select Key"}</label>
|
||||
<select class="form-select" id="keySelect" name="keySelect" required=true>
|
||||
<option value="">{"Choose a key..."}</option>
|
||||
{for available_keys.iter().map(|key| {
|
||||
html! {
|
||||
<option value={key.clone()}>{key}</option>
|
||||
}
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">{"Password"}</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required=true />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{"Login"}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_register_form(&self, ctx: &Context<Self>) -> Html {
|
||||
let on_submit = {
|
||||
let link = ctx.link().clone();
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
// Get form data directly from the form
|
||||
let form = e.target_dyn_into::<web_sys::HtmlFormElement>().unwrap();
|
||||
let form_data = web_sys::FormData::new_with_form(&form).unwrap();
|
||||
|
||||
let key_name = form_data.get("keyName").as_string().unwrap_or_default();
|
||||
let private_key = form_data.get("privateKey").as_string().unwrap_or_default();
|
||||
let password = form_data.get("regPassword").as_string().unwrap_or_default();
|
||||
|
||||
if !key_name.is_empty() && !private_key.is_empty() && !password.is_empty() {
|
||||
link.send_message(AuthMsg::RegisterKey(key_name, private_key, password));
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{"Register New Key"}</h5>
|
||||
<button type="button" class="btn-close" onclick={ctx.link().callback(|_| AuthMsg::HideForm)}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{if let Some(error) = &self.error_message {
|
||||
html! {
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="mb-3">
|
||||
<label for="keyName" class="form-label">{"Key Name"}</label>
|
||||
<input type="text" class="form-control" id="keyName" name="keyName" placeholder="e.g., My Main Key" required=true />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="privateKey" class="form-label">{"Private Key (64 hex characters)"}</label>
|
||||
<input type="text" class="form-control" id="privateKey" name="privateKey" placeholder="Enter your secp256k1 private key..." required=true />
|
||||
<div class="form-text">{"Your private key will be encrypted and stored locally."}</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="regPassword" class="form-label">{"Password"}</label>
|
||||
<input type="password" class="form-control" id="regPassword" name="regPassword" placeholder="Enter a strong password..." required=true />
|
||||
<div class="form-text">{"This password will be used to encrypt your private key."}</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{"Register Key"}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_generate_key_form(&self, ctx: &Context<Self>) -> Html {
|
||||
let on_submit = {
|
||||
let link = ctx.link().clone();
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
// Get form data directly from the form
|
||||
let form = e.target_dyn_into::<web_sys::HtmlFormElement>().unwrap();
|
||||
let form_data = web_sys::FormData::new_with_form(&form).unwrap();
|
||||
|
||||
let name = form_data.get("genName").as_string().unwrap_or_default();
|
||||
let password = form_data.get("genPassword").as_string().unwrap_or_default();
|
||||
|
||||
if !name.is_empty() && !password.is_empty() {
|
||||
link.send_message(AuthMsg::GenerateKey(name, password));
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_close = {
|
||||
let link = ctx.link().clone();
|
||||
Callback::from(move |_| {
|
||||
link.send_message(AuthMsg::HideForm);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{"Generate New Key"}</h5>
|
||||
<button type="button" class="btn-close" onclick={on_close}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{if let Some(error) = &self.error_message {
|
||||
html! {
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="mb-3">
|
||||
<label for="genName" class="form-label">{"Key Name"}</label>
|
||||
<input type="text" class="form-control" id="genName" name="genName" placeholder="Enter a name for your key..." required=true />
|
||||
<div class="form-text">{"Choose a memorable name for your new key."}</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="genPassword" class="form-label">{"Password"}</label>
|
||||
<input type="password" class="form-control" id="genPassword" name="genPassword" placeholder="Enter a strong password..." required=true />
|
||||
<div class="form-text">{"This password will be used to encrypt your generated private key."}</div>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>{"Note:"}</strong> {" A new cryptographic key will be generated automatically. Keep your password safe as it's needed to access your key."}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">{"Generate & Register Key"}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
100
components/src/error.rs
Normal file
100
components/src/error.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
//! Error types for the WebSocket connection manager
|
||||
|
||||
use thiserror::Error;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use hero_websocket_client::CircleWsClientError;
|
||||
|
||||
/// Result type alias for WebSocket operations
|
||||
pub type WsResult<T> = Result<T, WsError>;
|
||||
|
||||
/// Main error type for WebSocket connection manager operations
|
||||
#[derive(Error, Debug)]
|
||||
pub enum WsError {
|
||||
/// WebSocket client error from the underlying circle_client_ws library
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[error("WebSocket client error: {0}")]
|
||||
Client(#[from] CircleWsClientError),
|
||||
|
||||
/// Connection not found for the given URL
|
||||
#[error("No connection found for URL: {0}")]
|
||||
ConnectionNotFound(String),
|
||||
|
||||
/// Connection already exists for the given URL
|
||||
#[error("Connection already exists for URL: {0}")]
|
||||
ConnectionExists(String),
|
||||
|
||||
/// Authentication configuration error
|
||||
#[error("Authentication error: {0}")]
|
||||
Auth(String),
|
||||
|
||||
/// JSON serialization/deserialization error
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
/// Configuration error
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
/// Script execution error
|
||||
#[error("Script execution error on {url}: {message}")]
|
||||
ScriptExecution { url: String, message: String },
|
||||
|
||||
/// Connection timeout error
|
||||
#[error("Connection timeout for URL: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
/// Invalid URL format
|
||||
#[error("Invalid URL format: {0}")]
|
||||
InvalidUrl(String),
|
||||
|
||||
/// Manager not initialized
|
||||
#[error("WebSocket manager not properly initialized")]
|
||||
NotInitialized,
|
||||
|
||||
/// Generic error with custom message
|
||||
#[error("{0}")]
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl WsError {
|
||||
/// Create a custom error with a message
|
||||
pub fn custom<S: Into<String>>(message: S) -> Self {
|
||||
WsError::Custom(message.into())
|
||||
}
|
||||
|
||||
/// Create an authentication error
|
||||
pub fn auth<S: Into<String>>(message: S) -> Self {
|
||||
WsError::Auth(message.into())
|
||||
}
|
||||
|
||||
/// Create a configuration error
|
||||
pub fn config<S: Into<String>>(message: S) -> Self {
|
||||
WsError::Config(message.into())
|
||||
}
|
||||
|
||||
/// Create a script execution error
|
||||
pub fn script_execution<S: Into<String>>(url: S, message: S) -> Self {
|
||||
WsError::ScriptExecution {
|
||||
url: url.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an invalid URL error
|
||||
pub fn invalid_url<S: Into<String>>(url: S) -> Self {
|
||||
WsError::InvalidUrl(url.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert from string for convenience
|
||||
impl From<String> for WsError {
|
||||
fn from(message: String) -> Self {
|
||||
WsError::Custom(message)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for WsError {
|
||||
fn from(message: &str) -> Self {
|
||||
WsError::Custom(message.to_string())
|
||||
}
|
||||
}
|
1775
components/src/file_browser.rs
Normal file
1775
components/src/file_browser.rs
Normal file
File diff suppressed because it is too large
Load Diff
50
components/src/lib.rs
Normal file
50
components/src/lib.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Components Library
|
||||
//!
|
||||
//! This library provides reusable UI components for building web applications
|
||||
//! with Rust, Yew, and WebAssembly.
|
||||
|
||||
// Core UI components
|
||||
pub mod file_browser;
|
||||
pub mod markdown_editor;
|
||||
pub mod text_editor;
|
||||
pub mod toast;
|
||||
|
||||
// Auth and utilities (moved from parent)
|
||||
pub mod auth;
|
||||
pub mod browser_auth;
|
||||
pub mod error;
|
||||
pub mod ws_manager;
|
||||
|
||||
// Re-export components for easy access
|
||||
pub use file_browser::FileBrowser;
|
||||
pub use markdown_editor::MarkdownEditor;
|
||||
pub use text_editor::TextEditor;
|
||||
pub use toast::Toast;
|
||||
|
||||
// Re-export utilities
|
||||
pub use auth::*;
|
||||
pub use browser_auth::*;
|
||||
pub use error::*;
|
||||
pub use ws_manager::*;
|
||||
|
||||
// Re-export hero_websocket_client types that users might need
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use hero_websocket_client::{
|
||||
CircleWsClient, CircleWsClientBuilder, CircleWsClientError, methods::PlayResultClient
|
||||
};
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use yew::Callback;
|
||||
|
||||
/// Version information
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Prelude module for convenient imports
|
||||
pub mod prelude {
|
||||
pub use crate::{
|
||||
FileBrowser, MarkdownEditor, TextEditor, Toast,
|
||||
BrowserAuth, WsManager, WsManagerBuilder, WsManagerError, AuthConfig
|
||||
};
|
||||
|
||||
pub use crate::VERSION;
|
||||
}
|
3
components/src/main.rs
Normal file
3
components/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
301
components/src/markdown_editor.rs
Normal file
301
components/src/markdown_editor.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::{HtmlTextAreaElement, window};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::{spawn_local, JsFuture};
|
||||
use gloo_net::http::Request;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct MarkdownEditorProps {
|
||||
pub file_path: String,
|
||||
pub base_endpoint: String,
|
||||
}
|
||||
|
||||
pub struct MarkdownEditor {
|
||||
content: String,
|
||||
rendered_html: String,
|
||||
loading: bool,
|
||||
error: Option<String>,
|
||||
textarea_ref: NodeRef,
|
||||
}
|
||||
|
||||
pub enum MarkdownEditorMsg {
|
||||
LoadFile,
|
||||
FileLoaded(String),
|
||||
FileLoadError(String),
|
||||
ContentChanged(String),
|
||||
SaveFile,
|
||||
FileSaved,
|
||||
FileSaveError(String),
|
||||
}
|
||||
|
||||
impl Component for MarkdownEditor {
|
||||
type Message = MarkdownEditorMsg;
|
||||
type Properties = MarkdownEditorProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let editor = Self {
|
||||
content: String::new(),
|
||||
rendered_html: String::new(),
|
||||
loading: true,
|
||||
error: None,
|
||||
textarea_ref: NodeRef::default(),
|
||||
};
|
||||
|
||||
// Load file content on creation
|
||||
ctx.link().send_message(MarkdownEditorMsg::LoadFile);
|
||||
|
||||
editor
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
MarkdownEditorMsg::LoadFile => {
|
||||
let file_url = format!("{}/download/{}", ctx.props().base_endpoint, ctx.props().file_path);
|
||||
let link = ctx.link().clone();
|
||||
|
||||
spawn_local(async move {
|
||||
match Request::get(&file_url).send().await {
|
||||
Ok(response) => {
|
||||
match response.text().await {
|
||||
Ok(content) => {
|
||||
link.send_message(MarkdownEditorMsg::FileLoaded(content));
|
||||
}
|
||||
Err(e) => {
|
||||
link.send_message(MarkdownEditorMsg::FileLoadError(format!("Failed to read file: {}", e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
link.send_message(MarkdownEditorMsg::FileLoadError(format!("Failed to load file: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
MarkdownEditorMsg::FileLoaded(content) => {
|
||||
self.content = content.clone();
|
||||
self.rendered_html = self.render_markdown(&content);
|
||||
self.loading = false;
|
||||
self.error = None;
|
||||
true
|
||||
}
|
||||
|
||||
MarkdownEditorMsg::FileLoadError(error) => {
|
||||
self.loading = false;
|
||||
self.error = Some(error);
|
||||
true
|
||||
}
|
||||
|
||||
MarkdownEditorMsg::ContentChanged(content) => {
|
||||
self.content = content.clone();
|
||||
self.rendered_html = self.render_markdown(&content);
|
||||
true
|
||||
}
|
||||
|
||||
MarkdownEditorMsg::SaveFile => {
|
||||
let save_url = format!("{}/upload/{}", ctx.props().base_endpoint, ctx.props().file_path);
|
||||
let content = self.content.clone();
|
||||
let link = ctx.link().clone();
|
||||
|
||||
spawn_local(async move {
|
||||
let form_data = web_sys::FormData::new().unwrap();
|
||||
let blob = web_sys::Blob::new_with_str_sequence(&js_sys::Array::of1(&content.into())).unwrap();
|
||||
form_data.append_with_blob("file", &blob).unwrap();
|
||||
|
||||
let mut request_init = web_sys::RequestInit::new();
|
||||
request_init.set_method("POST");
|
||||
let js_value: wasm_bindgen::JsValue = form_data.into();
|
||||
request_init.set_body(&js_value);
|
||||
|
||||
let request = web_sys::Request::new_with_str_and_init(
|
||||
&save_url,
|
||||
&request_init
|
||||
).unwrap();
|
||||
|
||||
match JsFuture::from(window().unwrap().fetch_with_request(&request)).await {
|
||||
Ok(_) => {
|
||||
link.send_message(MarkdownEditorMsg::FileSaved);
|
||||
}
|
||||
Err(e) => {
|
||||
link.send_message(MarkdownEditorMsg::FileSaveError(format!("Failed to save file: {:?}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
MarkdownEditorMsg::FileSaved => {
|
||||
// Show success message or update UI
|
||||
false
|
||||
}
|
||||
|
||||
MarkdownEditorMsg::FileSaveError(error) => {
|
||||
self.error = Some(error);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let filename = ctx.props().file_path.split('/').last().unwrap_or(&ctx.props().file_path);
|
||||
|
||||
if self.loading {
|
||||
return html! {
|
||||
<div class="d-flex justify-content-center align-items-center" style="height: 100vh;">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(error) = &self.error {
|
||||
return html! {
|
||||
<div class="alert alert-danger m-3" role="alert">
|
||||
<h4 class="alert-heading">{"Error"}</h4>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
let oninput = ctx.link().callback(|e: InputEvent| {
|
||||
let target = e.target_dyn_into::<HtmlTextAreaElement>().unwrap();
|
||||
MarkdownEditorMsg::ContentChanged(target.value())
|
||||
});
|
||||
|
||||
let onsave = ctx.link().callback(|_| MarkdownEditorMsg::SaveFile);
|
||||
|
||||
html! {
|
||||
<div class="container-fluid h-100">
|
||||
// Header with filename and save button
|
||||
<div class="row bg-light border-bottom p-2">
|
||||
<div class="col">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-file-earmark-text me-2"></i>
|
||||
{filename}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={onsave}
|
||||
>
|
||||
<i class="bi bi-save me-1"></i>
|
||||
{"Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Split-screen editor
|
||||
<div class="row h-100">
|
||||
// Left pane: Text editor
|
||||
<div class="col-6 p-0">
|
||||
<div class="h-100 d-flex flex-column">
|
||||
<div class="bg-secondary text-white p-2 small">
|
||||
{"Markdown Editor"}
|
||||
</div>
|
||||
<textarea
|
||||
ref={self.textarea_ref.clone()}
|
||||
class="form-control border-0 flex-grow-1"
|
||||
style="resize: none; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 14px;"
|
||||
value={self.content.clone()}
|
||||
oninput={oninput}
|
||||
placeholder="Enter your markdown content here..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Right pane: Live preview
|
||||
<div class="col-6 p-0 border-start">
|
||||
<div class="h-100 d-flex flex-column">
|
||||
<div class="bg-info text-white p-2 small">
|
||||
{"Live Preview"}
|
||||
</div>
|
||||
<div
|
||||
class="flex-grow-1 p-3 overflow-auto"
|
||||
style="background-color: #fff;"
|
||||
>
|
||||
<div class="markdown-content">
|
||||
{Html::from_html_unchecked(AttrValue::from(self.rendered_html.clone()))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownEditor {
|
||||
fn render_markdown(&self, content: &str) -> String {
|
||||
// For now, use a simple JavaScript-based markdown renderer
|
||||
// In a real implementation, you might want to use a Rust markdown parser
|
||||
let window = window().unwrap();
|
||||
let marked_fn = js_sys::Reflect::get(&window, &"marked".into()).unwrap();
|
||||
|
||||
if marked_fn.is_function() {
|
||||
let marked_fn = marked_fn.dyn_into::<js_sys::Function>().unwrap();
|
||||
let result = marked_fn.call1(&window, &content.into()).unwrap();
|
||||
result.as_string().unwrap_or_else(|| content.to_string())
|
||||
} else {
|
||||
// Fallback: simple markdown transformations using basic string operations
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let mut result = String::new();
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("### ") {
|
||||
result.push_str(&format!("<h3>{}</h3>", &trimmed[4..]));
|
||||
} else if trimmed.starts_with("## ") {
|
||||
result.push_str(&format!("<h2>{}</h2>", &trimmed[3..]));
|
||||
} else if trimmed.starts_with("# ") {
|
||||
result.push_str(&format!("<h1>{}</h1>", &trimmed[2..]));
|
||||
} else if trimmed.is_empty() {
|
||||
result.push_str("<br>");
|
||||
} else {
|
||||
// Basic bold and italic (simple cases)
|
||||
let mut processed = trimmed.to_string();
|
||||
|
||||
// Simple bold replacement (**text**)
|
||||
while let Some(start) = processed.find("**") {
|
||||
if let Some(end) = processed[start + 2..].find("**") {
|
||||
let end = end + start + 2;
|
||||
let bold_text = &processed[start + 2..end];
|
||||
processed = format!(
|
||||
"{}<strong>{}</strong>{}",
|
||||
&processed[..start],
|
||||
bold_text,
|
||||
&processed[end + 2..]
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple code replacement (`code`)
|
||||
while let Some(start) = processed.find('`') {
|
||||
if let Some(end) = processed[start + 1..].find('`') {
|
||||
let end = end + start + 1;
|
||||
let code_text = &processed[start + 1..end];
|
||||
processed = format!(
|
||||
"{}<code>{}</code>{}",
|
||||
&processed[..start],
|
||||
code_text,
|
||||
&processed[end + 1..]
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result.push_str(&format!("<p>{}</p>", processed));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
10
components/src/mod.rs
Normal file
10
components/src/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod toast;
|
||||
pub mod file_browser;
|
||||
pub mod markdown_editor;
|
||||
pub mod text_editor;
|
||||
|
||||
pub use file_browser::{FileBrowser, FileBrowserConfig};
|
||||
pub use markdown_editor::MarkdownEditor;
|
||||
pub use text_editor::TextEditor;
|
||||
|
||||
pub use toast::*;
|
202
components/src/text_editor.rs
Normal file
202
components/src/text_editor.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::{window, HtmlTextAreaElement};
|
||||
use wasm_bindgen_futures::{spawn_local, JsFuture};
|
||||
use gloo_net::http::Request;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct TextEditorProps {
|
||||
pub file_path: String,
|
||||
pub base_endpoint: String,
|
||||
}
|
||||
|
||||
pub struct TextEditor {
|
||||
content: String,
|
||||
loading: bool,
|
||||
error: Option<String>,
|
||||
textarea_ref: NodeRef,
|
||||
}
|
||||
|
||||
pub enum TextEditorMsg {
|
||||
LoadFile,
|
||||
FileLoaded(String),
|
||||
FileLoadError(String),
|
||||
ContentChanged(String),
|
||||
SaveFile,
|
||||
FileSaved,
|
||||
FileSaveError(String),
|
||||
}
|
||||
|
||||
impl Component for TextEditor {
|
||||
type Message = TextEditorMsg;
|
||||
type Properties = TextEditorProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let editor = Self {
|
||||
content: String::new(),
|
||||
loading: true,
|
||||
error: None,
|
||||
textarea_ref: NodeRef::default(),
|
||||
};
|
||||
|
||||
// Load file content on creation
|
||||
ctx.link().send_message(TextEditorMsg::LoadFile);
|
||||
|
||||
editor
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
TextEditorMsg::LoadFile => {
|
||||
let file_url = format!("{}/download/{}", ctx.props().base_endpoint, ctx.props().file_path);
|
||||
let link = ctx.link().clone();
|
||||
|
||||
spawn_local(async move {
|
||||
match Request::get(&file_url).send().await {
|
||||
Ok(response) => {
|
||||
match response.text().await {
|
||||
Ok(content) => {
|
||||
link.send_message(TextEditorMsg::FileLoaded(content));
|
||||
}
|
||||
Err(e) => {
|
||||
link.send_message(TextEditorMsg::FileLoadError(format!("Failed to read file: {}", e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
link.send_message(TextEditorMsg::FileLoadError(format!("Failed to load file: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
TextEditorMsg::FileLoaded(content) => {
|
||||
self.content = content;
|
||||
self.loading = false;
|
||||
self.error = None;
|
||||
true
|
||||
}
|
||||
|
||||
TextEditorMsg::FileLoadError(error) => {
|
||||
self.loading = false;
|
||||
self.error = Some(error);
|
||||
true
|
||||
}
|
||||
|
||||
TextEditorMsg::ContentChanged(content) => {
|
||||
self.content = content;
|
||||
true
|
||||
}
|
||||
|
||||
TextEditorMsg::SaveFile => {
|
||||
let save_url = format!("{}/upload/{}", ctx.props().base_endpoint, ctx.props().file_path);
|
||||
let content = self.content.clone();
|
||||
let link = ctx.link().clone();
|
||||
|
||||
spawn_local(async move {
|
||||
let form_data = web_sys::FormData::new().unwrap();
|
||||
let blob = web_sys::Blob::new_with_str_sequence(&js_sys::Array::of1(&content.into())).unwrap();
|
||||
form_data.append_with_blob("file", &blob).unwrap();
|
||||
|
||||
let mut request_init = web_sys::RequestInit::new();
|
||||
request_init.set_method("POST");
|
||||
let js_value: wasm_bindgen::JsValue = form_data.into();
|
||||
request_init.set_body(&js_value);
|
||||
|
||||
let request = web_sys::Request::new_with_str_and_init(
|
||||
&save_url,
|
||||
&request_init
|
||||
).unwrap();
|
||||
|
||||
match JsFuture::from(window().unwrap().fetch_with_request(&request)).await {
|
||||
Ok(_) => {
|
||||
link.send_message(TextEditorMsg::FileSaved);
|
||||
}
|
||||
Err(e) => {
|
||||
link.send_message(TextEditorMsg::FileSaveError(format!("Failed to save file: {:?}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
TextEditorMsg::FileSaved => {
|
||||
// Show success message or update UI
|
||||
false
|
||||
}
|
||||
|
||||
TextEditorMsg::FileSaveError(error) => {
|
||||
self.error = Some(error);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let filename = ctx.props().file_path.split('/').last().unwrap_or(&ctx.props().file_path);
|
||||
|
||||
if self.loading {
|
||||
return html! {
|
||||
<div class="d-flex justify-content-center align-items-center" style="height: 100vh;">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(error) = &self.error {
|
||||
return html! {
|
||||
<div class="alert alert-danger m-3" role="alert">
|
||||
<h4 class="alert-heading">{"Error"}</h4>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
let oninput = ctx.link().callback(|e: InputEvent| {
|
||||
let target = e.target_dyn_into::<HtmlTextAreaElement>().unwrap();
|
||||
TextEditorMsg::ContentChanged(target.value())
|
||||
});
|
||||
|
||||
let onsave = ctx.link().callback(|_| TextEditorMsg::SaveFile);
|
||||
|
||||
html! {
|
||||
<div class="container-fluid h-100">
|
||||
// Header with filename and save button
|
||||
<div class="row bg-light border-bottom p-2">
|
||||
<div class="col">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-file-earmark-text me-2"></i>
|
||||
{filename}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={onsave}
|
||||
>
|
||||
<i class="bi bi-save me-1"></i>
|
||||
{"Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Full-screen text editor
|
||||
<div class="row h-100">
|
||||
<div class="col p-0">
|
||||
<textarea
|
||||
ref={self.textarea_ref.clone()}
|
||||
class="form-control border-0 h-100"
|
||||
style="resize: none; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 14px;"
|
||||
value={self.content.clone()}
|
||||
oninput={oninput}
|
||||
placeholder="Enter your text content here..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
342
components/src/toast.rs
Normal file
342
components/src/toast.rs
Normal file
@@ -0,0 +1,342 @@
|
||||
use yew::prelude::*;
|
||||
use gloo::timers::callback::Timeout;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ToastType {
|
||||
Success,
|
||||
Error,
|
||||
Warning,
|
||||
Info,
|
||||
Loading,
|
||||
}
|
||||
|
||||
impl ToastType {
|
||||
pub fn to_bootstrap_class(&self) -> &'static str {
|
||||
match self {
|
||||
ToastType::Success => "text-bg-success",
|
||||
ToastType::Error => "text-bg-danger",
|
||||
ToastType::Warning => "text-bg-warning",
|
||||
ToastType::Info => "text-bg-info",
|
||||
ToastType::Loading => "text-bg-primary",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_icon(&self) -> &'static str {
|
||||
match self {
|
||||
ToastType::Success => "✓",
|
||||
ToastType::Error => "✕",
|
||||
ToastType::Warning => "⚠",
|
||||
ToastType::Info => "ℹ",
|
||||
ToastType::Loading => "⟳",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Toast {
|
||||
pub id: String,
|
||||
pub message: String,
|
||||
pub toast_type: ToastType,
|
||||
pub duration: Option<u32>, // Duration in milliseconds, None for persistent
|
||||
pub dismissible: bool,
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
pub fn new(id: String, message: String, toast_type: ToastType) -> Self {
|
||||
Self {
|
||||
id,
|
||||
message,
|
||||
toast_type,
|
||||
duration: Some(5000), // Default 5 seconds
|
||||
dismissible: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn success(id: String, message: String) -> Self {
|
||||
Self::new(id, message, ToastType::Success)
|
||||
}
|
||||
|
||||
pub fn error(id: String, message: String) -> Self {
|
||||
Self::new(id, message, ToastType::Error)
|
||||
}
|
||||
|
||||
pub fn warning(id: String, message: String) -> Self {
|
||||
Self::new(id, message, ToastType::Warning)
|
||||
}
|
||||
|
||||
pub fn info(id: String, message: String) -> Self {
|
||||
Self::new(id, message, ToastType::Info)
|
||||
}
|
||||
|
||||
pub fn loading(id: String, message: String) -> Self {
|
||||
Self::new(id, message, ToastType::Loading).persistent()
|
||||
}
|
||||
|
||||
pub fn persistent(mut self) -> Self {
|
||||
self.duration = None;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_duration(mut self, duration: u32) -> Self {
|
||||
self.duration = Some(duration);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn non_dismissible(mut self) -> Self {
|
||||
self.dismissible = false;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ToastContainerProps {
|
||||
pub toasts: Vec<Toast>,
|
||||
pub on_remove: Callback<String>,
|
||||
}
|
||||
|
||||
#[function_component(ToastContainer)]
|
||||
pub fn toast_container(props: &ToastContainerProps) -> Html {
|
||||
html! {
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1055;">
|
||||
{for props.toasts.iter().map(|toast| {
|
||||
let on_close = if toast.dismissible {
|
||||
let on_remove = props.on_remove.clone();
|
||||
let id = toast.id.clone();
|
||||
Some(Callback::from(move |_| on_remove.emit(id.clone())))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class={classes!("toast", "show", toast.toast_type.to_bootstrap_class())} role="alert">
|
||||
<div class="toast-header">
|
||||
<span class="me-auto">
|
||||
<span class="me-2">{toast.toast_type.to_icon()}</span>
|
||||
{"Notification"}
|
||||
</span>
|
||||
{if let Some(on_close) = on_close {
|
||||
html! { <button type="button" class="btn-close" onclick={on_close}></button> }
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{&toast.message}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced toast manager with update capabilities
|
||||
pub struct ToastManager {
|
||||
toasts: HashMap<String, Toast>,
|
||||
timeouts: HashMap<String, Timeout>,
|
||||
}
|
||||
|
||||
impl ToastManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
toasts: HashMap::new(),
|
||||
timeouts: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_or_update_toast(&mut self, toast: Toast, on_remove: Callback<String>) {
|
||||
let id = toast.id.clone();
|
||||
|
||||
// Cancel existing timeout if any
|
||||
if let Some(timeout) = self.timeouts.remove(&id) {
|
||||
timeout.cancel();
|
||||
}
|
||||
|
||||
// Set up auto-removal timeout if duration is specified
|
||||
if let Some(duration) = toast.duration {
|
||||
let timeout_id = id.clone();
|
||||
let timeout = Timeout::new(duration, move || {
|
||||
on_remove.emit(timeout_id);
|
||||
});
|
||||
self.timeouts.insert(id.clone(), timeout);
|
||||
}
|
||||
|
||||
self.toasts.insert(id, toast);
|
||||
}
|
||||
|
||||
pub fn remove_toast(&mut self, id: &str) {
|
||||
self.toasts.remove(id);
|
||||
if let Some(timeout) = self.timeouts.remove(id) {
|
||||
timeout.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_all(&mut self) {
|
||||
self.toasts.clear();
|
||||
for (_, timeout) in self.timeouts.drain() {
|
||||
timeout.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_toasts(&self) -> Vec<Toast> {
|
||||
self.toasts.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn has_toast(&self, id: &str) -> bool {
|
||||
self.toasts.contains_key(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced hook with update capabilities
|
||||
#[hook]
|
||||
pub fn use_toast() -> (Vec<Toast>, Callback<Toast>, Callback<String>) {
|
||||
let manager = use_mut_ref(|| ToastManager::new());
|
||||
let toasts = use_state(|| Vec::<Toast>::new());
|
||||
|
||||
let _update_toasts = {
|
||||
let manager = manager.clone();
|
||||
let toasts = toasts.clone();
|
||||
move || {
|
||||
let mgr = manager.borrow();
|
||||
toasts.set(mgr.get_toasts());
|
||||
}
|
||||
};
|
||||
|
||||
let add_or_update_toast = {
|
||||
let manager = manager.clone();
|
||||
let toasts = toasts.clone();
|
||||
|
||||
Callback::from(move |toast: Toast| {
|
||||
let toasts_setter = toasts.clone();
|
||||
let manager_clone = manager.clone();
|
||||
|
||||
let on_remove = Callback::from(move |id: String| {
|
||||
let mut mgr = manager_clone.borrow_mut();
|
||||
mgr.remove_toast(&id);
|
||||
toasts_setter.set(mgr.get_toasts());
|
||||
});
|
||||
|
||||
let mut mgr = manager.borrow_mut();
|
||||
mgr.add_or_update_toast(toast, on_remove);
|
||||
toasts.set(mgr.get_toasts());
|
||||
})
|
||||
};
|
||||
|
||||
let remove_toast = {
|
||||
let manager = manager.clone();
|
||||
let toasts = toasts.clone();
|
||||
|
||||
Callback::from(move |id: String| {
|
||||
let mut mgr = manager.borrow_mut();
|
||||
mgr.remove_toast(&id);
|
||||
toasts.set(mgr.get_toasts());
|
||||
})
|
||||
};
|
||||
|
||||
((*toasts).clone(), add_or_update_toast, remove_toast)
|
||||
}
|
||||
|
||||
// Convenience trait for easy toast operations
|
||||
pub trait ToastExt {
|
||||
fn toast_loading(&self, id: &str, message: &str);
|
||||
fn toast_success(&self, id: &str, message: &str);
|
||||
fn toast_error(&self, id: &str, message: &str);
|
||||
fn toast_warning(&self, id: &str, message: &str);
|
||||
fn toast_info(&self, id: &str, message: &str);
|
||||
fn toast_update(&self, id: &str, message: &str, toast_type: ToastType);
|
||||
fn toast_remove(&self, id: &str);
|
||||
}
|
||||
|
||||
pub struct ToastHandle {
|
||||
add_toast: Callback<Toast>,
|
||||
remove_toast: Callback<String>,
|
||||
}
|
||||
|
||||
impl ToastHandle {
|
||||
pub fn new(add_toast: Callback<Toast>, remove_toast: Callback<String>) -> Self {
|
||||
Self { add_toast, remove_toast }
|
||||
}
|
||||
}
|
||||
|
||||
impl ToastExt for ToastHandle {
|
||||
fn toast_loading(&self, id: &str, message: &str) {
|
||||
self.add_toast.emit(Toast::loading(id.to_string(), message.to_string()));
|
||||
}
|
||||
|
||||
fn toast_success(&self, id: &str, message: &str) {
|
||||
self.add_toast.emit(Toast::success(id.to_string(), message.to_string()));
|
||||
}
|
||||
|
||||
fn toast_error(&self, id: &str, message: &str) {
|
||||
self.add_toast.emit(Toast::error(id.to_string(), message.to_string()));
|
||||
}
|
||||
|
||||
fn toast_warning(&self, id: &str, message: &str) {
|
||||
self.add_toast.emit(Toast::warning(id.to_string(), message.to_string()));
|
||||
}
|
||||
|
||||
fn toast_info(&self, id: &str, message: &str) {
|
||||
self.add_toast.emit(Toast::info(id.to_string(), message.to_string()));
|
||||
}
|
||||
|
||||
fn toast_update(&self, id: &str, message: &str, toast_type: ToastType) {
|
||||
self.add_toast.emit(Toast::new(id.to_string(), message.to_string(), toast_type));
|
||||
}
|
||||
|
||||
fn toast_remove(&self, id: &str) {
|
||||
self.remove_toast.emit(id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced hook that returns a ToastHandle for easier usage
|
||||
#[hook]
|
||||
pub fn use_toast_handle() -> (Vec<Toast>, ToastHandle, Callback<String>) {
|
||||
let (toasts, add_toast, remove_toast) = use_toast();
|
||||
let handle = ToastHandle::new(add_toast, remove_toast.clone());
|
||||
(toasts, handle, remove_toast)
|
||||
}
|
||||
|
||||
// Async operation helper
|
||||
pub struct AsyncToastOperation {
|
||||
handle: ToastHandle,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl AsyncToastOperation {
|
||||
pub fn new(handle: ToastHandle, id: String, loading_message: String) -> Self {
|
||||
handle.toast_loading(&id, &loading_message);
|
||||
Self { handle, id }
|
||||
}
|
||||
|
||||
pub fn success(self, message: String) {
|
||||
self.handle.toast_success(&self.id, &message);
|
||||
}
|
||||
|
||||
pub fn error(self, message: String) {
|
||||
self.handle.toast_error(&self.id, &message);
|
||||
}
|
||||
|
||||
pub fn update(&self, message: String, toast_type: ToastType) {
|
||||
self.handle.toast_update(&self.id, &message, toast_type);
|
||||
}
|
||||
|
||||
pub fn remove(self) {
|
||||
self.handle.toast_remove(&self.id);
|
||||
}
|
||||
}
|
||||
|
||||
impl ToastHandle {
|
||||
pub fn async_operation(&self, id: &str, loading_message: &str) -> AsyncToastOperation {
|
||||
AsyncToastOperation::new(self.clone(), id.to_string(), loading_message.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for ToastHandle {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
add_toast: self.add_toast.clone(),
|
||||
remove_toast: self.remove_toast.clone(),
|
||||
}
|
||||
}
|
||||
}
|
385
components/src/ws_manager.rs
Normal file
385
components/src/ws_manager.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
//! WebSocket Manager
|
||||
//!
|
||||
//! A lightweight manager for multiple self-managing WebSocket connections.
|
||||
//! Handles connection lifecycle, authentication, and keep-alive for multiple WebSocket URLs.
|
||||
//! Provides access to live authorized clients for external use.
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! The `WsManager` is designed to:
|
||||
//! - Connect to multiple WebSocket servers simultaneously
|
||||
//! - Handle authentication using private keys
|
||||
//! - Maintain persistent connections with automatic keep-alive
|
||||
//! - Provide safe access to connected clients for script execution and other operations
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```rust
|
||||
//! use framework::WsManager;
|
||||
//!
|
||||
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! // Create a manager with multiple servers
|
||||
//! let manager = WsManager::builder()
|
||||
//! .private_key("your_private_key_hex".to_string())
|
||||
//! .add_server_url("ws://localhost:3030".to_string())
|
||||
//! .add_server_url("ws://localhost:3031".to_string())
|
||||
//! .build();
|
||||
//!
|
||||
//! // Connect to all servers
|
||||
//! manager.connect().await?;
|
||||
//!
|
||||
//! // Execute operations on a specific server
|
||||
//! if let Some(result) = manager.with_client("ws://localhost:3030", |client| {
|
||||
//! client.play("console.log('Hello World!');".to_string())
|
||||
//! }).await {
|
||||
//! match result {
|
||||
//! Ok(output) => println!("Script output: {}", output),
|
||||
//! Err(e) => eprintln!("Script error: {}", e),
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! // Work with all connected clients
|
||||
//! manager.with_all_clients(|clients| {
|
||||
//! for (url, client) in clients {
|
||||
//! if client.is_connected() {
|
||||
//! println!("Connected to: {}", url);
|
||||
//! }
|
||||
//! }
|
||||
//! });
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The manager uses a builder pattern for configuration and maintains clients in a `RefCell<HashMap>`
|
||||
//! for interior mutability. Since `CircleWsClient` doesn't implement `Clone`, access is provided
|
||||
//! through closure-based methods that ensure safe borrowing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use log::{error, info};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use hero_websocket_client::{CircleWsClient, CircleWsClientBuilder, CircleWsClientError};
|
||||
|
||||
/// Builder for creating a WebSocket manager
|
||||
///
|
||||
/// Use this to configure multiple WebSocket URLs and optional authentication
|
||||
/// before building the final `WsManager` instance.
|
||||
pub struct WsManagerBuilder {
|
||||
private_key: Option<String>,
|
||||
server_urls: Vec<String>,
|
||||
}
|
||||
|
||||
impl WsManagerBuilder {
|
||||
/// Create a new builder instance
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
private_key: None,
|
||||
server_urls: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set private key for authentication
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `private_key` - Hex-encoded private key for signing authentication challenges
|
||||
pub fn private_key(mut self, private_key: String) -> Self {
|
||||
self.private_key = Some(private_key);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a server URL to connect to
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `url` - WebSocket URL (e.g., "ws://localhost:3030")
|
||||
pub fn add_server_url(mut self, url: String) -> Self {
|
||||
self.server_urls.push(url);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the manager and create clients for all URLs
|
||||
///
|
||||
/// Creates `CircleWsClient` instances for each configured URL.
|
||||
/// Clients are not connected until `connect()` is called.
|
||||
pub fn build(self) -> WsManager {
|
||||
let mut clients = HashMap::new();
|
||||
|
||||
for url in self.server_urls {
|
||||
let client = if let Some(ref private_key) = self.private_key {
|
||||
CircleWsClientBuilder::new(url.clone())
|
||||
.with_keypair(private_key.clone())
|
||||
.build()
|
||||
} else {
|
||||
CircleWsClientBuilder::new(url.clone()).build()
|
||||
};
|
||||
|
||||
clients.insert(url, client);
|
||||
}
|
||||
|
||||
WsManager {
|
||||
clients: Rc::new(RefCell::new(clients)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight WebSocket manager with pre-created clients
|
||||
#[derive(Clone)]
|
||||
pub struct WsManager {
|
||||
clients: Rc<RefCell<HashMap<String, CircleWsClient>>>,
|
||||
}
|
||||
|
||||
impl PartialEq for WsManager {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
// Compare based on the URLs of the clients
|
||||
let self_urls: std::collections::BTreeSet<_> = self.clients.borrow().keys().cloned().collect();
|
||||
let other_urls: std::collections::BTreeSet<_> = other.clients.borrow().keys().cloned().collect();
|
||||
self_urls == other_urls
|
||||
}
|
||||
}
|
||||
|
||||
impl WsManager {
|
||||
/// Create a new builder
|
||||
pub fn builder() -> WsManagerBuilder {
|
||||
WsManagerBuilder::new()
|
||||
}
|
||||
|
||||
/// Connect all pre-created clients
|
||||
/// Each client manages its own connection lifecycle after this
|
||||
pub async fn connect(&self) -> Result<(), CircleWsClientError> {
|
||||
let urls: Vec<String> = self.clients.borrow().keys().cloned().collect();
|
||||
|
||||
let mut successful = 0;
|
||||
let mut failed_urls = Vec::new();
|
||||
let mut clients = self.clients.borrow_mut();
|
||||
|
||||
for url in &urls {
|
||||
if let Some(client) = clients.get_mut(url) {
|
||||
match client.connect().await {
|
||||
Ok(_) => {
|
||||
// Try to authenticate if the client was built with a private key
|
||||
match client.authenticate().await {
|
||||
Ok(_) => {
|
||||
successful += 1;
|
||||
}
|
||||
Err(_) => {
|
||||
// Auth failed or not required - still count as successful connection
|
||||
successful += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
failed_urls.push(url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only log summary, not individual connection attempts
|
||||
if successful > 0 {
|
||||
info!("Connected to {}/{} servers", successful, urls.len());
|
||||
}
|
||||
|
||||
if !failed_urls.is_empty() {
|
||||
info!("Failed to connect to: {:?}", failed_urls);
|
||||
}
|
||||
|
||||
if successful == 0 && !urls.is_empty() {
|
||||
return Err(CircleWsClientError::NotConnected);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Execute a closure with access to a connected client
|
||||
///
|
||||
/// This is the preferred way to access clients since CircleWsClient doesn't implement Clone.
|
||||
/// The closure is only executed if the client exists and is connected.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `url` - The WebSocket URL of the target server
|
||||
/// * `f` - Closure that receives a reference to the connected client
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Some(R)` - Result of the closure if client is connected
|
||||
/// * `None` - If client doesn't exist or is not connected
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// # use framework::WsManager;
|
||||
/// # async fn example(manager: &WsManager) {
|
||||
/// let result = manager.with_client("ws://localhost:3030", |client| {
|
||||
/// client.play("console.log('Hello!');".to_string())
|
||||
/// }).await;
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn with_client<F, R>(&self, url: &str, f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce(&CircleWsClient) -> R,
|
||||
{
|
||||
let clients = self.clients.borrow();
|
||||
if let Some(client) = clients.get(url) {
|
||||
if client.is_connected() {
|
||||
return Some(f(client));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Execute a closure with access to all clients
|
||||
///
|
||||
/// Provides access to the entire HashMap of clients, including both connected
|
||||
/// and disconnected ones. Use `client.is_connected()` to check status.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `f` - Closure that receives a reference to the clients HashMap
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// # use framework::WsManager;
|
||||
/// # fn example(manager: &WsManager) {
|
||||
/// manager.with_all_clients(|clients| {
|
||||
/// for (url, client) in clients {
|
||||
/// if client.is_connected() {
|
||||
/// println!("Connected to: {}", url);
|
||||
/// }
|
||||
/// }
|
||||
/// });
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn with_all_clients<F, R>(&self, f: F) -> R
|
||||
where
|
||||
F: FnOnce(&HashMap<String, CircleWsClient>) -> R,
|
||||
{
|
||||
let clients = self.clients.borrow();
|
||||
f(&*clients)
|
||||
}
|
||||
|
||||
/// Get connected server URLs
|
||||
pub fn get_connected_urls(&self) -> Vec<String> {
|
||||
self.clients.borrow().keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get configured server URLs
|
||||
pub fn get_server_urls(&self) -> Vec<String> {
|
||||
self.clients.borrow().keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get connection status for all servers
|
||||
pub fn get_all_connection_statuses(&self) -> std::collections::HashMap<String, String> {
|
||||
let mut statuses = std::collections::HashMap::new();
|
||||
let clients = self.clients.borrow();
|
||||
for (url, client) in clients.iter() {
|
||||
let status = client.get_connection_status();
|
||||
statuses.insert(url.clone(), status);
|
||||
}
|
||||
statuses
|
||||
}
|
||||
|
||||
/// Check if connected to a server
|
||||
pub fn is_connected(&self, url: &str) -> bool {
|
||||
let clients = self.clients.borrow();
|
||||
if let Some(client) = clients.get(url) {
|
||||
client.is_connected()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get connection count
|
||||
pub fn connection_count(&self) -> usize {
|
||||
self.clients.borrow().len()
|
||||
}
|
||||
|
||||
/// Add a new WebSocket connection at runtime
|
||||
pub fn add_connection(&self, url: String, private_key: Option<String>) {
|
||||
let client = if let Some(private_key) = private_key {
|
||||
CircleWsClientBuilder::new(url.clone())
|
||||
.with_keypair(private_key)
|
||||
.build()
|
||||
} else {
|
||||
CircleWsClientBuilder::new(url.clone()).build()
|
||||
};
|
||||
|
||||
self.clients.borrow_mut().insert(url, client);
|
||||
}
|
||||
|
||||
/// Connect to a specific server
|
||||
pub async fn connect_to_server(&self, url: &str) -> Result<(), CircleWsClientError> {
|
||||
let mut clients = self.clients.borrow_mut();
|
||||
if let Some(client) = clients.get_mut(url) {
|
||||
match client.connect().await {
|
||||
Ok(_) => {
|
||||
// Try to authenticate if the client was built with a private key
|
||||
match client.authenticate().await {
|
||||
Ok(_) => {
|
||||
info!("Connected and authenticated to {}", url);
|
||||
}
|
||||
Err(_) => {
|
||||
// Auth failed or not required - still count as successful connection
|
||||
info!("Connected to {} (no auth)", url);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to connect to {}: {}", url, e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(CircleWsClientError::NotConnected)
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from a specific server
|
||||
pub async fn disconnect_from_server(&self, url: &str) -> Result<(), CircleWsClientError> {
|
||||
let mut clients = self.clients.borrow_mut();
|
||||
if let Some(client) = clients.get_mut(url) {
|
||||
client.disconnect().await;
|
||||
info!("Disconnected from {}", url);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CircleWsClientError::NotConnected)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a connection entirely
|
||||
pub fn remove_connection(&self, url: &str) -> bool {
|
||||
self.clients.borrow_mut().remove(url).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
// Clients handle their own cleanup when dropped
|
||||
impl Drop for WsManager {
|
||||
fn drop(&mut self) {
|
||||
// Silent cleanup - clients handle their own lifecycle
|
||||
}
|
||||
}
|
||||
|
||||
/// Type alias for backward compatibility
|
||||
pub type WsConnectionManager = WsManager;
|
||||
|
||||
/// Convenience function to create a manager builder
|
||||
pub fn ws_manager() -> WsManagerBuilder {
|
||||
WsManager::builder()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_builder() {
|
||||
let manager = ws_manager()
|
||||
.private_key("test_key".to_string())
|
||||
.add_server_url("ws://localhost:8080".to_string())
|
||||
.build();
|
||||
|
||||
assert_eq!(manager.get_server_urls().len(), 1);
|
||||
assert_eq!(manager.get_server_urls()[0], "ws://localhost:8080");
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user