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 4584e68..b31e9ed 100644
--- a/gui/assets/main.scss
+++ b/gui/assets/main.scss
@@ -432,3 +432,163 @@ 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);
+}
+
+/* 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;
+}
diff --git a/gui/src/main.rs b/gui/src/main.rs
index f246ad2..d2e6dfe 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, VERSION,
};
-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::*;
@@ -498,183 +496,246 @@ pub fn ServerView(overrides: Resource) -> Element {
}
#[component]
-pub fn LoginView(overrides: Resource) -> Element {
- let user_config = use_context::();
+fn ServerCard(
+ server: ServerEntry,
+ overrides: Resource,
+) -> Element {
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 address = format!("{}:{}", server.address, server.port);
+ let connect_entry = server.clone();
- 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;
+ rsx!(
+ 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.name}" }
+ span { class: "server-card__address", "{address}" }
+ }
+ ServerPingInfo {
+ address: server.address.clone(),
+ port: server.port,
+ }
+ button {
+ class: "server-card__action server-card__action--connect",
+ onclick: {
+ let entry = connect_entry.clone();
+ move |_| {
+ 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",
+ }
}
}
- });
+ )
+}
+
+#[component]
+fn OverrideLoginView(overrides: Resource) -> Element {
+ let user_config = use_context::();
+ let net: Coroutine = use_coroutine_handle();
+ let state = use_context::();
+
+ 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(String::new())
+ .unwrap_or_default()
});
- 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! {
- 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}"
- }
- }
- ),
- Connected => unreachable!(),
- };
+ let is_connecting = matches!(&*state.status.read(), Connecting);
+
+
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 {
+ class: "server-list",
div {
- label {
- for: "address-entry",
- "Server Address:"
+ 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 {
- id: "address-entry",
- placeholder: "address",
- value: "{address.read()}",
- autofocus: "true",
- oninput: move |evt| address_input.set(Some(evt.value().clone())),
+ 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",
+ }
}
}
- }
- 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"
+ match &*state.status.read() {
+ Failed(msg) => rsx!(
+ div {
+ class: "login_error",
+ "Failed to connect:"
+ pre { "{msg}" }
}
- }),
- 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"
- }
- }),
+ ),
+ _ => rsx!(),
}
- div {
- {bottom}
- }
-
}
}
)
- // 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]
+pub fn LoginView(overrides: Resource) -> Element {
+ let user_config = use_context::();
+ let state = use_context::();
+
+ let servers = use_signal(|| {
+ user_config
+ .config_get::>("servers")
+ .unwrap_or_default()
+ });
+
+ 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 {
+ return rsx!(OverrideLoginView { overrides });
+ }
+
+ // --- Normal mode: editable server list ---
+ rsx!(
+ div {
+ class: "server-list-page",
+ h1 {
+ "Mumble Web"
+ match VERSION {
+ Some(v) => rsx!(div { class: "login_version", "({v})" }),
+ None => rsx!(),
+ }
+ }
+ div {
+ class: "server-list",
+ for (idx, server) in servers.read().iter().enumerate() {
+ ServerCard {
+ key: "{idx}",
+ server: server.clone(),
+ overrides,
+ }
+ }
+ }
+ match &*state.status.read() {
+ Failed(msg) => rsx!(
+ div {
+ class: "server-list",
+ div {
+ class: "login_error",
+ "Failed to connect:"
+ pre { "{msg}" }
+ }
+ }
+ ),
+ _ => rsx!(),
+ }
+ }
+ )
+}
+
+#[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]