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..4d8c69c 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::*;
@@ -498,184 +496,514 @@ pub fn ServerView(overrides: Resource) -> Element {
}
#[component]
-pub fn LoginView(overrides: Resource) -> Element {
+fn OverrideLoginView(overrides: Resource) -> Element {
let user_config = use_context::();
let net: Coroutine = use_coroutine_handle();
+ let state = use_context::();
- 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 version = option_env!("MUMBLE_WEB2_VERSION");
- 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 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 version = option_env!("MUMBLE_WEB2_VERSION");
+ 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 net: Coroutine = use_coroutine_handle();
+ let state = use_context::();
+
+ 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 {
+ 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() {
+ {
+ 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",
+ }
+ }
+ }
+ )
+ }
+ }
+ }
+ 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"
+ }
+
+ 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),
+ })
+ }
+ }
+ }
+ }
+ )
+}
+
+#[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]