From d8d7475fc436e1de176c754f7fc33467f6cd15fe Mon Sep 17 00:00:00 2001 From: Sam Sartor Date: Mon, 4 May 2026 23:04:03 -0600 Subject: [PATCH] gui: new login screen --- client/src/app.rs | 8 +- client/src/imp/connect.rs | 18 +- client/src/imp/desktop.rs | 12 +- client/src/imp/mobile.rs | 12 +- client/src/imp/mod.rs | 4 +- client/src/imp/native_config.rs | 8 +- client/src/imp/stub.rs | 7 +- client/src/imp/web.rs | 12 +- client/src/mainloop.rs | 4 +- common/src/lib.rs | 10 + gui/assets/arrow-right-svgrepo-com.svg | 4 + gui/assets/delete-2-svgrepo-com.svg | 8 + gui/assets/earth-14-svgrepo-com.svg | 135 ++++++ gui/assets/edit-3-svgrepo-com.svg | 5 + gui/assets/main.scss | 345 ++++++++++++++ gui/src/main.rs | 636 ++++++++++++++++++------- 16 files changed, 1039 insertions(+), 189 deletions(-) create mode 100644 gui/assets/arrow-right-svgrepo-com.svg create mode 100644 gui/assets/delete-2-svgrepo-com.svg create mode 100644 gui/assets/earth-14-svgrepo-com.svg create mode 100644 gui/assets/edit-3-svgrepo-com.svg diff --git a/client/src/app.rs b/client/src/app.rs index 41f8721..67c128a 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -21,10 +21,16 @@ pub struct AudioSettings { pub denoise: bool, } +#[derive(Debug, Clone)] +pub enum ConnectTarget { + Direct { host: String, port: u16 }, + Proxy(String), +} + #[derive(Debug)] pub enum Command { Connect { - address: String, + target: ConnectTarget, username: String, config: ProxyOverrides, }, diff --git a/client/src/imp/connect.rs b/client/src/imp/connect.rs index b6654b4..155101d 100644 --- a/client/src/imp/connect.rs +++ b/client/src/imp/connect.rs @@ -1,5 +1,5 @@ -use crate::app::{Command, SharedState}; -use color_eyre::eyre::Error; +use crate::app::{Command, ConnectTarget, SharedState}; +use color_eyre::eyre::{bail, Error}; use futures_channel::mpsc::UnboundedReceiver; use mumble_protocol::control::ClientControlCodec; use std::net::ToSocketAddrs; @@ -70,7 +70,7 @@ impl ServerCertVerifier for NoCertificateVerification { #[instrument] pub async fn network_connect( - address: String, + target: ConnectTarget, username: String, event_rx: &mut UnboundedReceiver, overrides: &ProxyOverrides, @@ -78,6 +78,13 @@ pub async fn network_connect( ) -> Result<(), Error> { info!("connecting"); + let (host, port) = match target { + ConnectTarget::Direct { host, port } => (host, port), + ConnectTarget::Proxy(_) => { + bail!("desktop/mobile platform requires a direct host:port, not a proxy URL") + } + }; + let config = ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new(NoCertificateVerification)) @@ -85,15 +92,14 @@ pub async fn network_connect( let connector = TlsConnector::from(Arc::new(config)); - let addr = format!("{}:{}", address, 64738) + let addr = (&*host, port) .to_socket_addrs()? .next() .unwrap(); let server_tcp = TcpStream::connect(addr).await?; let server_stream = connector - //.connect("127.0.0.1".try_into()?, server_tcp) - .connect(address.try_into()?, server_tcp) + .connect(host.try_into()?, server_tcp) .await?; let (read_server, write_server) = tokio::io::split(server_stream); diff --git a/client/src/imp/desktop.rs b/client/src/imp/desktop.rs index 9da9e62..9df8f49 100644 --- a/client/src/imp/desktop.rs +++ b/client/src/imp/desktop.rs @@ -1,4 +1,4 @@ -use crate::app::{Command, SharedState}; +use crate::app::{Command, ConnectTarget, SharedState}; use color_eyre::eyre::Error; use futures_channel::mpsc::UnboundedReceiver; use mumble_web2_common::{ProxyOverrides, ServerStatus}; @@ -24,20 +24,24 @@ impl super::PlatformInterface for DesktopPlatform { } async fn network_connect( - address: String, + target: ConnectTarget, username: String, event_rx: &mut UnboundedReceiver, overrides: &ProxyOverrides, state: SharedState, ) -> Result<(), Error> { - super::connect::network_connect(address, username, event_rx, overrides, state).await + super::connect::network_connect(target, username, event_rx, overrides, state).await } async fn get_status( _client: &reqwest::Client, address: &str, ) -> color_eyre::Result { - mumble_web2_common::ping_server(address, 64738).await + let (host, port) = match address.rsplit_once(':') { + Some((h, p)) => (h, p.parse().unwrap_or(64738)), + None => (address, 64738), + }; + mumble_web2_common::ping_server(host, port).await } fn init_logging() { diff --git a/client/src/imp/mobile.rs b/client/src/imp/mobile.rs index a973920..9fb4906 100644 --- a/client/src/imp/mobile.rs +++ b/client/src/imp/mobile.rs @@ -1,4 +1,4 @@ -use crate::app::{Command, SharedState}; +use crate::app::{Command, ConnectTarget, SharedState}; use color_eyre::eyre::Error; use futures_channel::mpsc::UnboundedReceiver; use mumble_web2_common::{ProxyOverrides, ServerStatus}; @@ -20,20 +20,24 @@ impl super::PlatformInterface for MobilePlatform { } async fn network_connect( - address: String, + target: ConnectTarget, username: String, event_rx: &mut UnboundedReceiver, overrides: &ProxyOverrides, state: SharedState, ) -> Result<(), Error> { - super::connect::network_connect(address, username, event_rx, overrides, state).await + super::connect::network_connect(target, username, event_rx, overrides, state).await } async fn get_status( _client: &reqwest::Client, address: &str, ) -> color_eyre::Result { - mumble_web2_common::ping_server(address, 64738).await + let (host, port) = match address.rsplit_once(':') { + Some((h, p)) => (h, p.parse().unwrap_or(64738)), + None => (address, 64738), + }; + mumble_web2_common::ping_server(host, port).await } fn init_logging() { diff --git a/client/src/imp/mod.rs b/client/src/imp/mod.rs index c9bd707..4bdb032 100644 --- a/client/src/imp/mod.rs +++ b/client/src/imp/mod.rs @@ -4,7 +4,7 @@ //! The traits make the platform boundary explicit and provide compile-time verification. #![allow(async_fn_in_trait)] -use crate::app::{Command, SharedState}; +use crate::app::{Command, ConnectTarget, SharedState}; use crate::effects::AudioProcessor; use color_eyre::eyre::Error; use futures_channel::mpsc::UnboundedReceiver; @@ -79,7 +79,7 @@ pub trait PlatformInterface { /// Establish a connection to the Mumble server and run the network loop. fn network_connect( - address: String, + target: ConnectTarget, username: String, event_rx: &mut UnboundedReceiver, proxy_overrides: &ProxyOverrides, diff --git a/client/src/imp/native_config.rs b/client/src/imp/native_config.rs index 6ae3b20..1a0c3c7 100644 --- a/client/src/imp/native_config.rs +++ b/client/src/imp/native_config.rs @@ -28,12 +28,8 @@ impl super::ConfigSystemInterface for NativeConfigSystem { match serde_json::from_value::(value_untyped) { Ok(v) => Some(v), Err(_) => { - let default_value = config_get_default(key) - .expect("Default value required after config parse failure"); - Some( - serde_json::from_value::(default_value) - .expect("Default value could not be parsed"), - ) + let default_value = config_get_default(key)?; + serde_json::from_value::(default_value).ok() } } } diff --git a/client/src/imp/stub.rs b/client/src/imp/stub.rs index 6744514..5f4c42d 100644 --- a/client/src/imp/stub.rs +++ b/client/src/imp/stub.rs @@ -1,6 +1,9 @@ /// Stub implementation of the platform interface, so that we can /// `cargo check` without any --feature flags. -use crate::{app::SharedState, effects::AudioProcessor}; +use crate::{ + app::{ConnectTarget, SharedState}, + effects::AudioProcessor, +}; use color_eyre::eyre::Error; use futures_channel::mpsc::UnboundedReceiver; use mumble_web2_common::{ProxyOverrides, ServerStatus}; @@ -21,7 +24,7 @@ impl super::PlatformInterface for StubPlatform { } fn network_connect( - _address: String, + _target: ConnectTarget, _username: String, _event_rx: &mut UnboundedReceiver, _overrides: &ProxyOverrides, diff --git a/client/src/imp/web.rs b/client/src/imp/web.rs index 83080ec..b23219a 100644 --- a/client/src/imp/web.rs +++ b/client/src/imp/web.rs @@ -1,4 +1,4 @@ -use crate::app::{Command, SharedState}; +use crate::app::{Command, ConnectTarget, SharedState}; use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState}; use color_eyre::eyre::{bail, eyre, Error}; use crossbeam::atomic::AtomicCell; @@ -108,13 +108,19 @@ impl super::PlatformInterface for WebPlatform { } async fn network_connect( - address: String, + target: ConnectTarget, username: String, event_rx: &mut UnboundedReceiver, overrides: &ProxyOverrides, state: SharedState, ) -> Result<(), Error> { - network_connect(address, username, event_rx, overrides, state).await + let url = match target { + ConnectTarget::Proxy(url) => url, + ConnectTarget::Direct { .. } => { + bail!("web platform requires a proxy URL, not a direct host:port") + } + }; + network_connect(url, username, event_rx, overrides, state).await } async fn get_status( diff --git a/client/src/mainloop.rs b/client/src/mainloop.rs index b6451e5..0a369c4 100644 --- a/client/src/mainloop.rs +++ b/client/src/mainloop.rs @@ -39,7 +39,7 @@ use crate::imp::{ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver, state: SharedState) { loop { let Some(Command::Connect { - address, + target, username, config, }) = event_rx.next().await @@ -50,7 +50,7 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver, state: *state.server.write_unchecked() = Default::default(); *state.status.write_unchecked() = ConnectionState::Connecting; if let Err(error) = - Platform::network_connect(address, username, &mut event_rx, &config, state.clone()) + Platform::network_connect(target, username, &mut event_rx, &config, state.clone()) .await { error!("could not connect {:?}", error); diff --git a/common/src/lib.rs b/common/src/lib.rs index a3277a6..1c66ce2 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -17,6 +17,16 @@ pub struct ServerStatus { 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, +} + /// Mumble UDP ping protocol. /// /// Send a 12-byte packet: 4 zero bytes + 8-byte identifier. 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/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/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/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/assets/main.scss b/gui/assets/main.scss index 4584e68..439f7b7 100644 --- a/gui/assets/main.scss +++ b/gui/assets/main.scss @@ -432,3 +432,348 @@ 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__action { + 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__action img { + width: 20px; + height: 20px; + filter: brightness(0) invert(0.8); /* light gray */ + opacity: 0.75; + transition: opacity 0.15s; +} + +.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__action:hover img { + opacity: 1.0; +} + +.server-card__action: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); +} + + +.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 */ + background: #141414; + + 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); +} + +/* 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 { + 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/main.rs b/gui/src/main.rs index ecdccd4..ef0e5ad 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -3,12 +3,10 @@ use dioxus::prelude::*; use mumble_web2_client::{ network_entrypoint, reqwest, AudioSettings, ChannelId, Command, ConfigSystem, - ConfigSystemInterface as _, ConnectionState, Platform, PlatformInterface as _, ServerState, + ConfigSystemInterface as _, ConnectTarget, ConnectionState, Platform, PlatformInterface as _, SharedState, State, UserId, UserState, }; -use mumble_web2_common::{ProxyOverrides, ServerStatus}; -use std::collections::{HashMap, HashSet}; -use std::{fmt, sync::Arc}; +use mumble_web2_common::{ProxyOverrides, ServerEntry}; use Command::*; use ConnectionState::*; @@ -501,181 +499,501 @@ pub fn ServerView(overrides: Resource) -> Element { pub fn LoginView(overrides: Resource) -> Element { let user_config = use_context::(); let net: Coroutine = use_coroutine_handle(); - - let mut address_input = use_signal(|| user_config.config_get::("server_url")); - let address = use_memo(move || { - if let Some(addr) = address_input() { - addr.clone() - } else { - overrides() - .and_then(|c| c.proxy_url.clone()) - .unwrap_or_default() - } - }); - - let last_status = use_signal(|| None::>); - use_resource(move || { - let addr = address(); - async move { - let client = reqwest::Client::new(); - loop { - *last_status.write_unchecked() = - Some(Platform::get_status(&client, &addr).await); - Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await; - } - } - }); - - let mut username = use_signal(|| { - user_config - .config_get::("username") - .unwrap_or(String::new()) - }); - - let do_connect = move |_| { - let _ = user_config.config_set::("username", &username.read()); - if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) { - user_config.config_set::("server_url", &address.read()); - } - net.send(Connect { - address: address.read().clone(), - username: username.read().clone(), - config: overrides.read().clone().unwrap_or_default(), - }) - }; let state = use_context::(); - let status = &state.status; - let bottom = match &*status.read() { - Disconnected => rsx! { - button { - class: "login_bttn", - onclick: do_connect.clone(), - "Connect" - } - }, - Connecting => rsx! { + + let mut servers = use_signal(|| { + user_config + .config_get::>("servers") + .unwrap_or_default() + }); + let mut show_add_modal = use_signal(|| false); + let mut editing_index = use_signal(|| None::); + + let version = option_env!("MUMBLE_WEB2_VERSION"); + + let is_override_mode = overrides + .read() + .as_ref() + .is_some_and(|c| !c.any_server); + + // --- Overrides mode: single preset server, username-only input --- + if is_override_mode { + let proxy_url = overrides + .read() + .as_ref() + .and_then(|c| c.proxy_url.clone()) + .unwrap_or_default(); + + let mut username = use_signal(|| { + user_config + .config_get::("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(); + let user_config = user_config.clone(); + move |_| { + user_config.config_set("username", &*username.read()); + net.send(Connect { + target: ConnectTarget::Proxy(proxy_url.clone()), + username: username.read().clone(), + config: overrides.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!(), - }; - let version = option_env!("MUMBLE_WEB2_VERSION"); + ); + } + + // --- Normal mode: editable server list --- 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 overrides.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 (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(); + let user_config = user_config.clone(); + move |_| { + user_config.config_set("username", &entry.username); + net.send(Connect { + target: ConnectTarget::Direct { + host: entry.address.clone(), + port: entry.port, + }, + username: entry.username.clone(), + config: overrides.read().clone().unwrap_or_default(), + }); + } + }, + 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()), - } + 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" } - 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 server" - } - }), - } - div { - {bottom} - } + if *show_add_modal.read() { + { + let user_config = user_config.clone(); + rsx!(AddServerModal { + on_save: move |entry: ServerEntry| { + servers.write().push(entry); + user_config.config_set("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() { + { + let user_config_save = user_config.clone(); + let user_config_del = user_config.clone(); + rsx!(EditServerModal { + entry, + on_save: move |updated: ServerEntry| { + servers.write()[idx] = updated; + user_config_save.config_set("servers", &*servers.read()); + editing_index.set(None); + }, + on_delete: move |_| { + servers.write().remove(idx); + user_config_del.config_set("servers", &*servers.read()); + editing_index.set(None); + }, + on_cancel: move |_| editing_index.set(None), + }) + } + } } } ) - // 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} - // } - // ) +} + +#[component] +fn ServerPingInfo(address: String, port: u16) -> Element { + let ping_result = use_resource(move || { + let addr = format!("{}:{}", address.clone(), port); + async move { + let client = reqwest::Client::new(); + Platform::get_status(&client, &addr).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] +fn AddServerModal(on_save: EventHandler, on_cancel: EventHandler<()>) -> Element { + let user_config = use_context::(); + 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(|| { + user_config + .config_get::("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! { + div { + class: "modal-backdrop", + onclick: move |_| on_cancel.call(()), + } + 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 { + 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", + 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" + } + } + } + } + } +} + +#[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" + } + } + } + } + } } #[component]