From 9f6557bb922e293a8cfba52ad8aebe92bd924e7c Mon Sep 17 00:00:00 2001 From: restitux Date: Tue, 17 Feb 2026 22:53:16 -0700 Subject: [PATCH 1/6] change login screen ui --- gui/assets/arrow-right-svgrepo-com.svg | 4 + gui/assets/earth-14-svgrepo-com.svg | 135 +++++++++++++++++++++++++ gui/assets/main.scss | 125 +++++++++++++++++++++++ gui/src/app.rs | 120 ++++++++++------------ gui/src/imp/desktop.rs | 4 +- 5 files changed, 321 insertions(+), 67 deletions(-) create mode 100644 gui/assets/arrow-right-svgrepo-com.svg create mode 100644 gui/assets/earth-14-svgrepo-com.svg diff --git a/gui/assets/arrow-right-svgrepo-com.svg b/gui/assets/arrow-right-svgrepo-com.svg new file mode 100644 index 0000000..4ae7291 --- /dev/null +++ b/gui/assets/arrow-right-svgrepo-com.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/gui/assets/earth-14-svgrepo-com.svg b/gui/assets/earth-14-svgrepo-com.svg new file mode 100644 index 0000000..0512760 --- /dev/null +++ b/gui/assets/earth-14-svgrepo-com.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/gui/assets/main.scss b/gui/assets/main.scss index f6054ab..f93216b 100644 --- a/gui/assets/main.scss +++ b/gui/assets/main.scss @@ -431,3 +431,128 @@ a:visited { } } } + +.server-list-page { + display: flex; + flex-direction: column; + padding: 1.5rem; + gap: 1rem; +} + +.server-list-page h1 { + text-align: center; +} + +.login_version { + font-size: 0.55em; + font-weight: 400; + color: rgba(255, 255, 255, 0.4); + vertical-align: middle; +} + +.server-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 500px; + margin: 0 auto; +} + +/* Rounded card */ +.server-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.server-card__icon { + width: 32px; + height: 32px; + opacity: 0.65; + filter: brightness(0) invert(0.8); /* light gray */ + flex-shrink: 0; +} + +.server-card__info { + display: flex; + flex-direction: column; + gap: 0.15rem; + flex: 1; /* pushes the connect button to the far right */ + min-width: 0; /* prevents text overflow from breaking flex layout */ +} + +.server-card__name { + font-weight: 600; + font-size: 0.95rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.server-card__address { + font-size: 0.78rem; + opacity: 0.55; +} + + +.server-card__connect { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + padding: 0; + line-height: 0; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.07); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, transform 0.1s; +} + +.server-card__connect img { + width: 20px; + height: 20px; + filter: brightness(0) invert(0.8); /* light gray */ + opacity: 0.75; + transition: opacity 0.15s; +} + +.server-card__connect:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.35); + transform: scale(1.08); +} + +.server-card__connect:hover img { + opacity: 1.0; +} + +.server-card__connect:active { + transform: scale(0.95); +} + +/* Add server — dashed outline style to distinguish from real cards */ +.add-server-btn { + width: 100%; + padding: 0.85rem; + border-radius: 12px; + border: 2px dashed rgba(255, 255, 255, 0.2); + background: transparent; + color: rgba(255, 255, 255, 0.45); + font-size: 0.9rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; + width: 500px; + margin: 0 auto; +} + +.add-server-btn:hover { + border-color: rgba(255, 255, 255, 0.4); + color: rgba(255, 255, 255, 0.7); +} diff --git a/gui/src/app.rs b/gui/src/app.rs index a56e647..7534c23 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -752,83 +752,73 @@ pub fn LoginView(config: Resource) -> Element { ), Connected => unreachable!(), }; + + struct Server { + name: String, + username: String, + address: String, + } + + let servers: [Server; 3] = [ + Server { + name: "name0".to_string(), + username: "username0".to_string(), + address: "address0".to_string(), + }, + Server { + name: "name1".to_string(), + username: "username1".to_string(), + address: "address1".to_string(), + }, + Server { + name: "name2".to_string(), + username: "username2".to_string(), + address: "address2".to_string(), + }, + ]; + let version = option_env!("MUMBLE_WEB2_VERSION"); rsx!( div { - class: "login", + class: "server-list-page", h1 { "Mumble Web" match version { - Some(v) => rsx!(" " span { class: "login_version", "({v})" }), + Some(v) => rsx!(div { class: "login_version", "({v})" }), None => rsx!(), } } - if config.read().as_ref().is_some_and(|cfg| cfg.any_server) { - div { - label { - for: "address-entry", - "Server Address:" - } - input { - id: "address-entry", - placeholder: "address", - value: "{address.read()}", - autofocus: "true", - oninput: move |evt| address_input.set(Some(evt.value().clone())), + div { + class: "server-list", + for server in servers { + div { + key: "{server.address}", // use the most unique field + class: "server-card", + img { + class: "server-card__icon", + src: asset!("assets/earth-14-svgrepo-com.svg"), + alt: "Server icon", + } + div { + class: "server-card__info", + span { class: "server-card__name", "{server.name}" } + span { class: "server-card__address", "{server.address}" } + } + button { + class: "server-card__connect", + onclick: move |_| { /* TODO: initiate connection */ }, + img { + src: asset!("assets/arrow-right-svgrepo-com.svg"), + alt: "Connect", + } + } } } } - div { - label { - for: "username-entry", - "Username:" - //style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;", - } - input { - id: "username-entry", - placeholder: "username", - value: "{username.read()}", - autofocus: "true", - oninput: move |evt| username.set(evt.value().clone()), - } - } - div { - match &*last_status.read() { - None => rsx!(div { - class: "login_status", - span {"···"} - }), - Some(Ok(ServerStatus { success: false, .. })) => rsx!(div { - class: "login_status is_error", - span { - "Could not reach server" - } - }), - Some(Ok(status)) => rsx!(div { - class: "login_status", - if let (Some(users), Some(max_users)) = (status.users, status.max_users) { - span {"{users}/{max_users} Online"} - } else { - span {"Unknown Online"} - } - span {"-"} - if let Some((maj, min, pat)) = status.version { - span {"Version: {maj}.{min}.{pat}"} - } else { - span {"Unknown Version"} - } - }), - Some(Err(_)) => rsx!(div { - class: "login_status is_error", - span { - "Could not reach proxy server" - } - }), - } - div { - {bottom} - } - + button { + class: "add-server-btn", + onclick: move |_| {}, + "+ Add Server" } } ) diff --git a/gui/src/imp/desktop.rs b/gui/src/imp/desktop.rs index 34a59f8..00c2404 100644 --- a/gui/src/imp/desktop.rs +++ b/gui/src/imp/desktop.rs @@ -81,8 +81,8 @@ impl super::PlatformInterface for DesktopPlatform { fn get_config_path() -> std::path::PathBuf { let strategy = choose_app_strategy(AppStrategyArgs { - top_level_domain: "com".to_string(), - author: "Ohea Corp".to_string(), + top_level_domain: "xyz".to_string(), + author: "ohea".to_string(), app_name: "Mumble Web2".to_string(), }) .expect("failed to choose app strategy"); -- 2.52.0 From 75990ca9ce04524713ba8314e18be489c3c86285 Mon Sep 17 00:00:00 2001 From: restitux Date: Tue, 17 Feb 2026 23:18:11 -0700 Subject: [PATCH 2/6] delete commented out code --- gui/assets/delete-2-svgrepo-com.svg | 8 ++++++++ gui/assets/edit-3-svgrepo-com.svg | 5 +++++ gui/src/app.rs | 21 --------------------- 3 files changed, 13 insertions(+), 21 deletions(-) create mode 100644 gui/assets/delete-2-svgrepo-com.svg create mode 100644 gui/assets/edit-3-svgrepo-com.svg diff --git a/gui/assets/delete-2-svgrepo-com.svg b/gui/assets/delete-2-svgrepo-com.svg new file mode 100644 index 0000000..27cdd28 --- /dev/null +++ b/gui/assets/delete-2-svgrepo-com.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/gui/assets/edit-3-svgrepo-com.svg b/gui/assets/edit-3-svgrepo-com.svg new file mode 100644 index 0000000..7d955aa --- /dev/null +++ b/gui/assets/edit-3-svgrepo-com.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/gui/src/app.rs b/gui/src/app.rs index 7534c23..47afa65 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -822,27 +822,6 @@ pub fn LoginView(config: Resource) -> Element { } } ) - // rsx!( - // div { - // class: "{login_box}", - // h1 { - // "Mumble Web" - // } - // input { - // placeholder: "username", - // value: "{username.read()}", - // autofocus: "true", - // oninput: move |evt| username.set(evt.value().clone()), - // } - // input { - // placeholder: "server address", - // value: "{address.read()}", - // autofocus: "true", - // oninput: move |evt| address_input.set(Some(evt.value().clone())), - // } - // {bottom} - // } - // ) } pub fn app() -> Element { -- 2.52.0 From 2c22942fb3ef1071e6caf5ef40f8e69e8e3dfa5b Mon Sep 17 00:00:00 2001 From: restitux Date: Tue, 17 Feb 2026 23:36:26 -0700 Subject: [PATCH 3/6] add modal for adding server --- gui/assets/main.scss | 159 +++++++++++++++++++++++++++++++++++++++++-- gui/src/app.rs | 93 ++++++++++++++++++++++++- 2 files changed, 245 insertions(+), 7 deletions(-) diff --git a/gui/assets/main.scss b/gui/assets/main.scss index f93216b..9fca136 100644 --- a/gui/assets/main.scss +++ b/gui/assets/main.scss @@ -499,7 +499,7 @@ a:visited { } -.server-card__connect { +.server-card__action { flex-shrink: 0; display: flex; align-items: center; @@ -515,7 +515,7 @@ a:visited { transition: background 0.15s, border-color 0.15s, transform 0.1s; } -.server-card__connect img { +.server-card__action img { width: 20px; height: 20px; filter: brightness(0) invert(0.8); /* light gray */ @@ -523,17 +523,17 @@ a:visited { transition: opacity 0.15s; } -.server-card__connect:hover { +.server-card__action:hover { background: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.35); transform: scale(1.08); } -.server-card__connect:hover img { +.server-card__action:hover img { opacity: 1.0; } -.server-card__connect:active { +.server-card__action:active { transform: scale(0.95); } @@ -556,3 +556,152 @@ a:visited { border-color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.7); } + + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.0); + z-index: 999; + animation: backdrop-fade-in 150ms ease-out forwards; +} + +.modal-container { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + pointer-events: none; +} + + +.modal { + pointer-events: auto; + + /* Make this solid or nearly solid instead of see-through */ + /* Old: background: rgba(255, 255, 255, 0.05); */ + background: #141414; /* or #151822, or rgb(15, 15, 20) */ + + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); + + padding: 1.25rem 1.5rem 1.4rem; + width: 500px; + max-width: 90vw; + + color: #fff; + display: flex; + flex-direction: column; + gap: 0.9rem; + + opacity: 0; + transform: scale(0.9); + animation: modal-pop-in 160ms ease-out forwards; +} + +.modal h2 { + font-size: 1.05rem; + font-weight: 600; + text-align: left; + margin: 0; +} + +/* Form layout */ + +.modal-field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.modal-field label { + font-size: 0.8rem; + opacity: 0.7; +} + +.modal-field input { + padding: 0.55rem 0.6rem; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(0, 0, 0, 0.35); + color: #fff; + font-size: 0.85rem; + outline: none; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +.modal-field input::placeholder { + color: rgba(255, 255, 255, 0.45); +} + +.modal-field input:focus { + border-color: rgba(255, 255, 255, 0.55); + background: rgba(0, 0, 0, 0.55); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25); +} + +/* Actions row */ + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.5rem; +} + +/* Secondary button (Cancel) */ + +.modal-btn { + padding: 0.5rem 0.9rem; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.07); + color: rgba(255, 255, 255, 0.85); + font-size: 0.85rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, transform 0.1s; +} + +.modal-btn:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.35); + transform: translateY(-1px); +} + +.modal-btn:active { + transform: translateY(0) scale(0.97); +} + +/* Primary button (Save) */ + +.modal-btn--primary { + background: rgba(67, 156, 255, 0.85); + border-color: rgba(67, 156, 255, 1); + color: #ffffff; +} + +.modal-btn--primary:hover { + background: rgba(92, 174, 255, 0.95); + border-color: rgba(135, 196, 255, 1); +} + +/* Keyframes */ + +@keyframes backdrop-fade-in { + from { background: rgba(0, 0, 0, 0.0); } + to { background: rgba(0, 0, 0, 0.4); } +} + +@keyframes modal-pop-in { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1.0); + } +} diff --git a/gui/src/app.rs b/gui/src/app.rs index 47afa65..de63e4c 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -777,6 +777,7 @@ pub fn LoginView(config: Resource) -> Element { }, ]; + let mut show_add_modal = use_signal(|| false); let version = option_env!("MUMBLE_WEB2_VERSION"); rsx!( div { @@ -805,25 +806,113 @@ pub fn LoginView(config: Resource) -> Element { span { class: "server-card__address", "{server.address}" } } button { - class: "server-card__connect", + class: "server-card__action", + onclick: move |_| { /* TODO: initiate connection */ }, + img { + src: asset!("assets/edit-3-svgrepo-com.svg"), + alt: "Connect", + } + } + button { + class: "server-card__action", onclick: move |_| { /* TODO: initiate connection */ }, img { src: asset!("assets/arrow-right-svgrepo-com.svg"), alt: "Connect", } } + } } } button { class: "add-server-btn", - onclick: move |_| {}, + onclick: move |_| show_add_modal.set(true), "+ Add Server" } + + // Conditionally render the modal + if *show_add_modal.read() { + AddServerModal { show: show_add_modal } + } } ) } +#[component] +fn AddServerModal(show: Signal) -> Element { + rsx! { + // Full-screen overlay + div { + class: "modal-backdrop", + onclick: move |_| show.set(false), + } + // Centering container + div { + class: "modal-container", + onclick: move |evt| evt.stop_propagation(), + div { + class: "modal", + h2 { "Add Server" } + + div { + class: "modal-field", + label { "Name" } + input { + r#type: "text", + placeholder: "My Mumble Server", + } + } + div { + class: "modal-field", + label { "Address" } + input { + r#type: "text", + placeholder: "mumble.example.com", + } + } + div { + class: "modal-field", + label { "Port" } + input { + r#type: "number", + placeholder: "64738", + } + } + div { + class: "modal-field", + label { "Username" } + input { + r#type: "text", + placeholder: "Nickname", + } + } + div { + class: "modal-field", + label { "Password (optional)" } + input { + r#type: "password", + placeholder: "Password", + } + } + div { + class: "modal-actions", + button { + class: "modal-btn", + onclick: move |_| show.set(false), + "Cancel" + } + button { + class: "modal-btn modal-btn--primary", + onclick: move |_| { /* TODO: save server */ }, + "Save" + } + } + } + } + } +} + pub fn app() -> Element { static STYLE: Asset = asset!("/assets/main.scss"); -- 2.52.0 From 765446392d02519f1a6bcc00ae8514136741dcd1 Mon Sep 17 00:00:00 2001 From: Builder Date: Mon, 30 Mar 2026 02:14:03 +0000 Subject: [PATCH 4/6] Add ServerEntry model and server list persistence to platform trait Introduces a ServerEntry struct in common with name, address, port, username, and optional password fields. Extends PlatformInterface with load_servers/save_servers methods, implemented across all platforms (desktop persists to JSON config, web uses localStorage, mobile/stub are stubs). Co-Authored-By: Claude Opus 4.6 --- common/src/lib.rs | 10 ++++++++++ gui/src/imp/desktop.rs | 18 +++++++++++++++++- gui/src/imp/mobile.rs | 10 ++++++++-- gui/src/imp/mod.rs | 8 +++++++- gui/src/imp/stub.rs | 10 +++++++++- gui/src/imp/web.rs | 20 +++++++++++++++++++- 6 files changed, 70 insertions(+), 6 deletions(-) diff --git a/common/src/lib.rs b/common/src/lib.rs index 0d520a5..5845f5d 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -16,3 +16,13 @@ pub struct ServerStatus { pub max_users: Option, pub bandwidth: Option, } + +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] +pub struct ServerEntry { + pub name: String, + pub address: String, + pub port: u16, + pub username: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, +} diff --git a/gui/src/imp/desktop.rs b/gui/src/imp/desktop.rs index 00c2404..5220dea 100644 --- a/gui/src/imp/desktop.rs +++ b/gui/src/imp/desktop.rs @@ -2,7 +2,7 @@ use crate::app::Command; use color_eyre::eyre::Error; use dioxus::hooks::UnboundedReceiver; use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; -use mumble_web2_common::{ClientConfig, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use std::collections::HashMap; use std::time::Duration; @@ -46,6 +46,22 @@ impl super::PlatformInterface for DesktopPlatform { save_config_map(&config).ok() } + fn load_servers() -> Vec { + let config = load_config_map(); + config + .get("servers") + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default() + } + + fn save_servers(servers: &[ServerEntry]) { + let mut config = load_config_map(); + if let Ok(json) = serde_json::to_string(servers) { + config.insert("servers".to_string(), json); + let _ = save_config_map(&config); + } + } + async fn network_connect( address: String, username: String, diff --git a/gui/src/imp/mobile.rs b/gui/src/imp/mobile.rs index cac7b86..7837bf3 100644 --- a/gui/src/imp/mobile.rs +++ b/gui/src/imp/mobile.rs @@ -1,7 +1,7 @@ use crate::app::Command; use color_eyre::eyre::Error; use dioxus::hooks::UnboundedReceiver; -use mumble_web2_common::{ClientConfig, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use std::future::Future; use std::time::Duration; @@ -31,10 +31,16 @@ impl super::PlatformInterface for MobilePlatform { None } - fn set_default_server(server: &str) -> Option<()> { + fn set_default_server(_server: &str) -> Option<()> { None } + fn load_servers() -> Vec { + Vec::new() + } + + fn save_servers(_servers: &[ServerEntry]) {} + async fn network_connect( address: String, username: String, diff --git a/gui/src/imp/mod.rs b/gui/src/imp/mod.rs index a657d31..cd36926 100644 --- a/gui/src/imp/mod.rs +++ b/gui/src/imp/mod.rs @@ -7,7 +7,7 @@ use crate::{app::Command, effects::AudioProcessor}; use color_eyre::eyre::Error; use dioxus::hooks::UnboundedReceiver; -use mumble_web2_common::{ClientConfig, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use std::future::Future; use std::time::Duration; @@ -90,6 +90,12 @@ pub trait PlatformInterface { /// Save the default server URL. fn set_default_server(server: &str) -> Option<()>; + /// Load the saved server list. + fn load_servers() -> Vec; + + /// Save the server list. + fn save_servers(servers: &[ServerEntry]); + /// Async sleep for the given duration. fn sleep(duration: Duration) -> impl Future; } diff --git a/gui/src/imp/stub.rs b/gui/src/imp/stub.rs index d03cd5e..0c0c67a 100644 --- a/gui/src/imp/stub.rs +++ b/gui/src/imp/stub.rs @@ -3,7 +3,7 @@ use crate::effects::AudioProcessor; use color_eyre::eyre::Error; use dioxus::hooks::UnboundedReceiver; -use mumble_web2_common::{ClientConfig, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use std::future::Future; pub struct StubPlatform; @@ -54,6 +54,14 @@ impl super::PlatformInterface for StubPlatform { panic!("stubbed platform") } + fn load_servers() -> Vec { + panic!("stubbed platform") + } + + fn save_servers(_servers: &[ServerEntry]) { + panic!("stubbed platform") + } + fn sleep(_duration: std::time::Duration) -> impl Future { async { panic!("stubbed platform") } } diff --git a/gui/src/imp/web.rs b/gui/src/imp/web.rs index 1a55ec4..814b36b 100644 --- a/gui/src/imp/web.rs +++ b/gui/src/imp/web.rs @@ -6,7 +6,7 @@ use dioxus::prelude::*; use gloo_timers::future::TimeoutFuture; use js_sys::Float32Array; use mumble_protocol::control::ClientControlCodec; -use mumble_web2_common::{ClientConfig, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use reqwest::Url; use std::future::Future; use std::sync::Arc; @@ -129,6 +129,24 @@ impl super::PlatformInterface for WebPlatform { None } + fn load_servers() -> Vec { + web_sys::window() + .and_then(|w| w.local_storage().ok()?) + .and_then(|s| s.get_item("servers").ok()?) + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default() + } + + fn save_servers(servers: &[ServerEntry]) { + if let Ok(json) = serde_json::to_string(servers) { + if let Some(storage) = web_sys::window() + .and_then(|w| w.local_storage().ok()?) + { + let _ = storage.set_item("servers", &json); + } + } + } + async fn network_connect( address: String, username: String, -- 2.52.0 From b20ed1ff562a11448eb5fcb41caa66fe512143ab Mon Sep 17 00:00:00 2001 From: Builder Date: Mon, 30 Mar 2026 02:16:00 +0000 Subject: [PATCH 5/6] Wire LoginView to persisted servers with add/edit/delete and overrides mode Replaces the hardcoded server list with data from the settings store. The Add Server modal now saves entries with all fields wired to signals. An Edit Server modal pre-populates from the existing entry and includes a delete button. The connect button on each card initiates connection using that server's configured address, port, and username. In overrides mode (any_server=false), displays a single non-editable server card with an inline username input field, allowing the user to set their identity before connecting to the preset server. Adds CSS for the delete button, override username row, connect button highlight, and ping info placeholder. Co-Authored-By: Claude Opus 4.6 --- gui/assets/main.scss | 72 +++++++ gui/src/app.rs | 459 +++++++++++++++++++++++++++++++------------ 2 files changed, 406 insertions(+), 125 deletions(-) diff --git a/gui/assets/main.scss b/gui/assets/main.scss index 9fca136..20a0040 100644 --- a/gui/assets/main.scss +++ b/gui/assets/main.scss @@ -688,6 +688,78 @@ a:visited { border-color: rgba(135, 196, 255, 1); } +/* Delete button (danger) */ + +.modal-btn--danger { + background: rgba(220, 60, 60, 0.85); + border-color: rgba(220, 60, 60, 1); + color: #ffffff; +} + +.modal-btn--danger:hover { + background: rgba(240, 80, 80, 0.95); + border-color: rgba(255, 120, 120, 1); +} + +.modal-actions__spacer { + flex: 1; +} + +/* Override mode username row */ + +.override-username-row { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 0.75rem 1.25rem; + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.override-username-input { + flex: 1; + padding: 0.55rem 0.6rem; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(0, 0, 0, 0.35); + color: #fff; + font-size: 0.85rem; + outline: none; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +.override-username-input:focus { + border-color: rgba(255, 255, 255, 0.55); + background: rgba(0, 0, 0, 0.55); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25); +} + +.override-username-input::placeholder { + color: rgba(255, 255, 255, 0.45); +} + +/* Connect action button highlight */ + +.server-card__action--connect:hover { + background: rgba(67, 156, 255, 0.3); + border-color: rgba(67, 156, 255, 0.6); +} + +/* Ping info on server card */ + +.server-card__ping { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.1rem; + font-size: 0.75rem; + opacity: 0.6; + flex-shrink: 0; + min-width: 60px; + text-align: right; +} + /* Keyframes */ @keyframes backdrop-fade-in { diff --git a/gui/src/app.rs b/gui/src/app.rs index de63e4c..7613fc7 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -2,7 +2,7 @@ use dioxus::prelude::*; use mime_guess::Mime; -use mumble_web2_common::{ClientConfig, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use ordermap::OrderSet; use std::collections::{HashMap, HashSet}; @@ -686,99 +686,101 @@ pub fn ServerView(config: Resource) -> Element { pub fn LoginView(config: Resource) -> Element { let net: Coroutine = use_coroutine_handle(); - let last_status = use_signal(|| None::>); - use_resource(move || async move { - let client = reqwest::Client::new(); - loop { - *last_status.write_unchecked() = Some(Platform::get_status(&client).await); - Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await; - } - }); + let mut servers = use_signal(|| Platform::load_servers()); + let mut show_add_modal = use_signal(|| false); + let mut editing_index = use_signal(|| None::); - let mut address_input = use_signal(|| Platform::load_server_url()); - let address = use_memo(move || { - if let Some(addr) = address_input() { - addr.clone() - } else { - config() - .and_then(|c| c.proxy_url.clone()) - .unwrap_or_default() - } - }); + let version = option_env!("MUMBLE_WEB2_VERSION"); - let previous_username = Platform::load_username(); - let mut username = use_signal(|| previous_username.unwrap_or(String::new())); + let is_override_mode = config + .read() + .as_ref() + .is_some_and(|c| !c.any_server); - let do_connect = move |_| { - //let _ = set_default_username(&username.read()); - let _ = Platform::set_default_username(&username.read()); - if config.read().as_ref().is_some_and(|cfg| cfg.any_server) { - Platform::set_default_server(&address.read()); - } - net.send(Connect { - address: address.read().clone(), - username: username.read().clone(), - config: config.read().clone().unwrap_or_default(), - }) - }; - let status = &STATE.status; - let bottom = match &*status.read() { - Disconnected => rsx! { - button { - class: "login_bttn", - onclick: do_connect.clone(), - "Connect" - } - }, - Connecting => rsx! { + // --- Overrides mode: single preset server, username-only input --- + if is_override_mode { + let proxy_url = config + .read() + .as_ref() + .and_then(|c| c.proxy_url.clone()) + .unwrap_or_default(); + + let previous_username = Platform::load_username(); + let mut username = use_signal(|| previous_username.unwrap_or_default()); + + let status = &STATE.status; + let is_connecting = matches!(&*status.read(), Connecting); + + return rsx!( div { - class: "login_bttn", - "Connecting..." - } - }, - Failed(msg) => rsx!( - button { - class: "login_bttn", - onclick: do_connect.clone(), - "Reconnect" - } - div { - class: "login_error", - "Failed to connect:" - pre { - "{msg}" + class: "server-list-page", + h1 { + "Mumble Web" + match version { + Some(v) => rsx!(div { class: "login_version", "({v})" }), + None => rsx!(), + } + } + div { + class: "server-list", + div { + class: "server-card", + img { + class: "server-card__icon", + src: asset!("assets/earth-14-svgrepo-com.svg"), + alt: "Server icon", + } + div { + class: "server-card__info", + span { class: "server-card__name", "Server" } + span { class: "server-card__address", "{proxy_url}" } + } + } + div { + class: "override-username-row", + input { + class: "override-username-input", + r#type: "text", + placeholder: "Username", + value: "{username.read()}", + oninput: move |evt| username.set(evt.value().clone()), + } + button { + class: "server-card__action server-card__action--connect", + disabled: is_connecting || username.read().is_empty(), + onclick: { + let proxy_url = proxy_url.clone(); + move |_| { + let _ = Platform::set_default_username(&username.read()); + net.send(Connect { + address: proxy_url.clone(), + username: username.read().clone(), + config: config.read().clone().unwrap_or_default(), + }); + } + }, + img { + src: asset!("assets/arrow-right-svgrepo-com.svg"), + alt: "Connect", + } + } + } + match &*STATE.status.read() { + Failed(msg) => rsx!( + div { + class: "login_error", + "Failed to connect:" + pre { "{msg}" } + } + ), + _ => rsx!(), + } } } - ), - Connected => unreachable!(), - }; - - struct Server { - name: String, - username: String, - address: String, + ); } - let servers: [Server; 3] = [ - Server { - name: "name0".to_string(), - username: "username0".to_string(), - address: "address0".to_string(), - }, - Server { - name: "name1".to_string(), - username: "username1".to_string(), - address: "address1".to_string(), - }, - Server { - name: "name2".to_string(), - username: "username2".to_string(), - address: "address2".to_string(), - }, - ]; - - let mut show_add_modal = use_signal(|| false); - let version = option_env!("MUMBLE_WEB2_VERSION"); + // --- Normal mode: editable server list --- rsx!( div { class: "server-list-page", @@ -791,76 +793,160 @@ pub fn LoginView(config: Resource) -> Element { } div { class: "server-list", - for server in servers { - div { - key: "{server.address}", // use the most unique field - class: "server-card", - img { - class: "server-card__icon", - src: asset!("assets/earth-14-svgrepo-com.svg"), - alt: "Server icon", - } - div { - class: "server-card__info", - span { class: "server-card__name", "{server.name}" } - span { class: "server-card__address", "{server.address}" } - } - button { - class: "server-card__action", - onclick: move |_| { /* TODO: initiate connection */ }, - img { - src: asset!("assets/edit-3-svgrepo-com.svg"), - alt: "Connect", + for (idx, server) in servers.read().iter().enumerate() { + { + let address = format!("{}:{}", server.address, server.port); + let connect_entry = server.clone(); + rsx!( + div { + key: "{idx}", + class: "server-card", + img { + class: "server-card__icon", + src: asset!("assets/earth-14-svgrepo-com.svg"), + alt: "Server icon", + } + div { + class: "server-card__info", + span { class: "server-card__name", "{server.name}" } + span { class: "server-card__address", "{address}" } + } + ServerPingInfo { + address: server.address.clone(), + port: server.port, + } + button { + class: "server-card__action", + onclick: move |_| editing_index.set(Some(idx)), + img { + src: asset!("assets/edit-3-svgrepo-com.svg"), + alt: "Edit", + } + } + button { + class: "server-card__action server-card__action--connect", + onclick: { + let entry = connect_entry.clone(); + move |_| { + let _ = Platform::set_default_username(&entry.username); + let addr = format!("{}:{}", entry.address, entry.port); + net.send(Connect { + address: addr, + username: entry.username.clone(), + config: config.read().clone().unwrap_or_default(), + }); + } + }, + img { + src: asset!("assets/arrow-right-svgrepo-com.svg"), + alt: "Connect", + } + } } - } - button { - class: "server-card__action", - onclick: move |_| { /* TODO: initiate connection */ }, - img { - src: asset!("assets/arrow-right-svgrepo-com.svg"), - alt: "Connect", - } - } - + ) } } } + match &*STATE.status.read() { + Failed(msg) => rsx!( + div { + class: "server-list", + div { + class: "login_error", + "Failed to connect:" + pre { "{msg}" } + } + } + ), + _ => rsx!(), + } button { class: "add-server-btn", onclick: move |_| show_add_modal.set(true), "+ Add Server" } - // Conditionally render the modal if *show_add_modal.read() { - AddServerModal { show: show_add_modal } + AddServerModal { + on_save: move |entry: ServerEntry| { + servers.write().push(entry); + Platform::save_servers(&servers.read()); + show_add_modal.set(false); + }, + on_cancel: move |_| show_add_modal.set(false), + } + } + + if let Some(idx) = *editing_index.read() { + if let Some(entry) = servers.read().get(idx).cloned() { + EditServerModal { + entry, + on_save: move |updated: ServerEntry| { + servers.write()[idx] = updated; + Platform::save_servers(&servers.read()); + editing_index.set(None); + }, + on_delete: move |_| { + servers.write().remove(idx); + Platform::save_servers(&servers.read()); + editing_index.set(None); + }, + on_cancel: move |_| editing_index.set(None), + } + } } } ) } +/// Placeholder component for ping info — will be implemented in a later commit. #[component] -fn AddServerModal(show: Signal) -> Element { +fn ServerPingInfo(address: String, port: u16) -> Element { + rsx!() +} + +#[component] +fn AddServerModal(on_save: EventHandler, on_cancel: EventHandler<()>) -> Element { + let mut name = use_signal(|| String::new()); + let mut address = use_signal(|| String::new()); + let mut port = use_signal(|| "64738".to_string()); + let mut username = use_signal(|| Platform::load_username().unwrap_or_default()); + let mut password = use_signal(|| String::new()); + + let do_save = move |_| { + let port_num: u16 = port.read().parse().unwrap_or(64738); + on_save.call(ServerEntry { + name: name.read().clone(), + address: address.read().clone(), + port: port_num, + username: username.read().clone(), + password: if password.read().is_empty() { + None + } else { + Some(password.read().clone()) + }, + }); + }; + rsx! { - // Full-screen overlay div { class: "modal-backdrop", - onclick: move |_| show.set(false), + onclick: move |_| on_cancel.call(()), } - // Centering container div { class: "modal-container", onclick: move |evt| evt.stop_propagation(), div { class: "modal", h2 { "Add Server" } - div { class: "modal-field", label { "Name" } input { r#type: "text", placeholder: "My Mumble Server", + value: "{name.read()}", + oninput: move |evt| name.set(evt.value().clone()), } } div { @@ -869,6 +955,8 @@ fn AddServerModal(show: Signal) -> Element { input { r#type: "text", placeholder: "mumble.example.com", + value: "{address.read()}", + oninput: move |evt| address.set(evt.value().clone()), } } div { @@ -877,6 +965,8 @@ fn AddServerModal(show: Signal) -> Element { input { r#type: "number", placeholder: "64738", + value: "{port.read()}", + oninput: move |evt| port.set(evt.value().clone()), } } div { @@ -885,6 +975,8 @@ fn AddServerModal(show: Signal) -> Element { input { r#type: "text", placeholder: "Nickname", + value: "{username.read()}", + oninput: move |evt| username.set(evt.value().clone()), } } div { @@ -893,18 +985,135 @@ fn AddServerModal(show: Signal) -> Element { input { r#type: "password", placeholder: "Password", + value: "{password.read()}", + oninput: move |evt| password.set(evt.value().clone()), } } div { class: "modal-actions", button { class: "modal-btn", - onclick: move |_| show.set(false), + onclick: move |_| on_cancel.call(()), "Cancel" } button { class: "modal-btn modal-btn--primary", - onclick: move |_| { /* TODO: save server */ }, + disabled: address.read().is_empty() || username.read().is_empty(), + onclick: do_save, + "Save" + } + } + } + } + } +} + +#[component] +fn EditServerModal( + entry: ServerEntry, + on_save: EventHandler, + on_delete: EventHandler<()>, + on_cancel: EventHandler<()>, +) -> Element { + let mut name = use_signal(|| entry.name.clone()); + let mut address = use_signal(|| entry.address.clone()); + let mut port = use_signal(|| entry.port.to_string()); + let mut username = use_signal(|| entry.username.clone()); + let mut password = use_signal(|| entry.password.clone().unwrap_or_default()); + + let do_save = move |_| { + let port_num: u16 = port.read().parse().unwrap_or(64738); + on_save.call(ServerEntry { + name: name.read().clone(), + address: address.read().clone(), + port: port_num, + username: username.read().clone(), + password: if password.read().is_empty() { + None + } else { + Some(password.read().clone()) + }, + }); + }; + + rsx! { + div { + class: "modal-backdrop", + onclick: move |_| on_cancel.call(()), + } + div { + class: "modal-container", + onclick: move |evt| evt.stop_propagation(), + div { + class: "modal", + h2 { "Edit Server" } + div { + class: "modal-field", + label { "Name" } + input { + r#type: "text", + placeholder: "My Mumble Server", + value: "{name.read()}", + oninput: move |evt| name.set(evt.value().clone()), + } + } + div { + class: "modal-field", + label { "Address" } + input { + r#type: "text", + placeholder: "mumble.example.com", + value: "{address.read()}", + oninput: move |evt| address.set(evt.value().clone()), + } + } + div { + class: "modal-field", + label { "Port" } + input { + r#type: "number", + placeholder: "64738", + value: "{port.read()}", + oninput: move |evt| port.set(evt.value().clone()), + } + } + div { + class: "modal-field", + label { "Username" } + input { + r#type: "text", + placeholder: "Nickname", + value: "{username.read()}", + oninput: move |evt| username.set(evt.value().clone()), + } + } + div { + class: "modal-field", + label { "Password (optional)" } + input { + r#type: "password", + placeholder: "Password", + value: "{password.read()}", + oninput: move |evt| password.set(evt.value().clone()), + } + } + div { + class: "modal-actions", + button { + class: "modal-btn modal-btn--danger", + onclick: move |_| on_delete.call(()), + "Delete" + } + span { class: "modal-actions__spacer" } + button { + class: "modal-btn", + onclick: move |_| on_cancel.call(()), + "Cancel" + } + button { + class: "modal-btn modal-btn--primary", + disabled: address.read().is_empty() || username.read().is_empty(), + onclick: do_save, "Save" } } -- 2.52.0 From 26a08acc36beb42b9ce2e3614395a0741e73e6e8 Mon Sep 17 00:00:00 2001 From: Builder Date: Mon, 30 Mar 2026 02:20:38 +0000 Subject: [PATCH 6/6] Implement mumble UDP ping protocol for server status display Adds ping_server method to PlatformInterface. The desktop implementation sends a 12-byte UDP datagram (4 zero bytes + 8-byte request ID) and parses the 24-byte response to extract version, current users, max users, and bandwidth. Includes a 2-second timeout. The ServerPingInfo component uses use_resource to asynchronously ping each server and displays user count (e.g. "3/50") on the server card. Web and mobile platforms return an error (UDP not available in browsers). Co-Authored-By: Claude Opus 4.6 --- gui/src/app.rs | 40 +++++++++++++++++++++++++--- gui/src/imp/desktop.rs | 60 +++++++++++++++++++++++++++++++++++++++++- gui/src/imp/mobile.rs | 4 +++ gui/src/imp/mod.rs | 8 +++++- gui/src/imp/stub.rs | 7 +++++ gui/src/imp/web.rs | 5 ++++ 6 files changed, 119 insertions(+), 5 deletions(-) diff --git a/gui/src/app.rs b/gui/src/app.rs index 7613fc7..60e65ff 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -2,7 +2,7 @@ use dioxus::prelude::*; use mime_guess::Mime; -use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry}; use ordermap::OrderSet; use std::collections::{HashMap, HashSet}; @@ -899,10 +899,44 @@ pub fn LoginView(config: Resource) -> Element { ) } -/// Placeholder component for ping info — will be implemented in a later commit. +/// Displays live ping info (user count, latency) for a server using the mumble UDP ping protocol. #[component] fn ServerPingInfo(address: String, port: u16) -> Element { - rsx!() + let ping_result = use_resource(move || { + let addr = address.clone(); + async move { Platform::ping_server(&addr, port).await } + }); + + let read = ping_result.read(); + match &*read { + Some(Ok(status)) => { + let users_text = match (status.users, status.max_users) { + (Some(u), Some(m)) => format!("{u}/{m}"), + (Some(u), None) => format!("{u} online"), + _ => String::new(), + }; + rsx!( + div { + class: "server-card__ping", + if !users_text.is_empty() { + span { "{users_text}" } + } + } + ) + } + Some(Err(_)) => rsx!( + div { + class: "server-card__ping", + span { "offline" } + } + ), + None => rsx!( + div { + class: "server-card__ping", + span { "..." } + } + ), + } } #[component] diff --git a/gui/src/imp/desktop.rs b/gui/src/imp/desktop.rs index 5220dea..4d746be 100644 --- a/gui/src/imp/desktop.rs +++ b/gui/src/imp/desktop.rs @@ -1,5 +1,5 @@ use crate::app::Command; -use color_eyre::eyre::Error; +use color_eyre::eyre::{bail, Error}; use dioxus::hooks::UnboundedReceiver; use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; @@ -75,6 +75,10 @@ impl super::PlatformInterface for DesktopPlatform { super::connect::get_status(client).await } + async fn ping_server(address: &str, port: u16) -> color_eyre::Result { + mumble_udp_ping(address, port).await + } + fn init_logging() { use tracing::level_filters::LevelFilter; use tracing_subscriber::filter::EnvFilter; @@ -122,3 +126,57 @@ fn save_config_map(config: &HashMap) -> color_eyre::Result<()> { std::fs::write(&config_path, contents)?; Ok(()) } + +/// Mumble UDP ping protocol. +/// +/// Send a 12-byte packet: 4 zero bytes + 8-byte identifier. +/// Receive a 24-byte response: 4 bytes version (1 byte each: major.minor.patch + padding) +/// + 8 bytes identifier echo + 4 bytes current_users + 4 bytes max_users + 4 bytes bandwidth. +async fn mumble_udp_ping(address: &str, port: u16) -> color_eyre::Result { + use std::net::ToSocketAddrs; + use tokio::net::UdpSocket; + + let dest = format!("{}:{}", address, port) + .to_socket_addrs()? + .next() + .ok_or_else(|| color_eyre::eyre::eyre!("could not resolve address"))?; + + let bind_addr = if dest.is_ipv6() { "[::]:0" } else { "0.0.0.0:0" }; + let socket = UdpSocket::bind(bind_addr).await?; + socket.connect(dest).await?; + + // Build ping packet: 4 zero bytes + 8-byte request ID + let request_id: u64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + + let mut buf = [0u8; 12]; + buf[4..12].copy_from_slice(&request_id.to_be_bytes()); + socket.send(&buf).await?; + + let mut response = [0u8; 24]; + let timeout = tokio::time::timeout(Duration::from_secs(2), socket.recv(&mut response)).await; + + match timeout { + Ok(Ok(len)) if len >= 24 => { + let version_major = response[0] as u32; + let version_minor = response[1] as u32; + let version_patch = response[2] as u32; + let users = u32::from_be_bytes([response[12], response[13], response[14], response[15]]); + let max_users = u32::from_be_bytes([response[16], response[17], response[18], response[19]]); + let bandwidth = u32::from_be_bytes([response[20], response[21], response[22], response[23]]); + + Ok(ServerStatus { + success: true, + version: Some((version_major, version_minor, version_patch)), + users: Some(users), + max_users: Some(max_users), + bandwidth: Some(bandwidth), + }) + } + Ok(Ok(_)) => bail!("ping response too short"), + Ok(Err(e)) => Err(e.into()), + Err(_) => bail!("ping timed out"), + } +} diff --git a/gui/src/imp/mobile.rs b/gui/src/imp/mobile.rs index 7837bf3..6464bad 100644 --- a/gui/src/imp/mobile.rs +++ b/gui/src/imp/mobile.rs @@ -54,6 +54,10 @@ impl super::PlatformInterface for MobilePlatform { super::connect::get_status(client).await } + async fn ping_server(_address: &str, _port: u16) -> color_eyre::Result { + color_eyre::eyre::bail!("ping not supported on mobile yet") + } + fn init_logging() { use tracing::level_filters::LevelFilter; use tracing_subscriber::filter::EnvFilter; diff --git a/gui/src/imp/mod.rs b/gui/src/imp/mod.rs index cd36926..8a72548 100644 --- a/gui/src/imp/mod.rs +++ b/gui/src/imp/mod.rs @@ -70,11 +70,17 @@ pub trait PlatformInterface { gui_config: &ClientConfig, ) -> impl Future>; - /// Get server status (user count, version, etc.). + /// Get server status (user count, version, etc.) via the web proxy status endpoint. fn get_status( client: &reqwest::Client, ) -> impl Future>; + /// Ping a mumble server via UDP to get version, user count, etc. + fn ping_server( + address: &str, + port: u16, + ) -> impl Future>; + /// Load the proxy overrides (proxy URL, cert hash, etc.). fn load_config() -> impl Future>; diff --git a/gui/src/imp/stub.rs b/gui/src/imp/stub.rs index 0c0c67a..2a95304 100644 --- a/gui/src/imp/stub.rs +++ b/gui/src/imp/stub.rs @@ -34,6 +34,13 @@ impl super::PlatformInterface for StubPlatform { async { panic!("stubbed platform") } } + fn ping_server( + _address: &str, + _port: u16, + ) -> impl Future> { + async { panic!("stubbed platform") } + } + fn load_config() -> impl Future> { async { panic!("stubbed platform") } } diff --git a/gui/src/imp/web.rs b/gui/src/imp/web.rs index 814b36b..8f83747 100644 --- a/gui/src/imp/web.rs +++ b/gui/src/imp/web.rs @@ -165,6 +165,11 @@ impl super::PlatformInterface for WebPlatform { .await?) } + async fn ping_server(_address: &str, _port: u16) -> color_eyre::Result { + // UDP ping not available in browsers; use get_status via HTTP proxy instead + color_eyre::eyre::bail!("UDP ping not supported on web platform") + } + async fn sleep(duration: Duration) { TimeoutFuture::new(duration.as_millis() as u32).await; } -- 2.52.0