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 <noreply@anthropic.com>
This commit is contained in:
Builder
2026-03-30 02:16:00 +00:00
parent 765446392d
commit b20ed1ff56
2 changed files with 406 additions and 125 deletions
+72
View File
@@ -688,6 +688,78 @@ a:visited {
border-color: rgba(135, 196, 255, 1); 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 */
@keyframes backdrop-fade-in { @keyframes backdrop-fade-in {
+334 -125
View File
@@ -2,7 +2,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use mime_guess::Mime; use mime_guess::Mime;
use mumble_web2_common::{ClientConfig, ServerStatus}; use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus};
use ordermap::OrderSet; use ordermap::OrderSet;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@@ -686,99 +686,101 @@ pub fn ServerView(config: Resource<ClientConfig>) -> Element {
pub fn LoginView(config: Resource<ClientConfig>) -> Element { pub fn LoginView(config: Resource<ClientConfig>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle(); let net: Coroutine<Command> = use_coroutine_handle();
let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>); let mut servers = use_signal(|| Platform::load_servers());
use_resource(move || async move { let mut show_add_modal = use_signal(|| false);
let client = reqwest::Client::new(); let mut editing_index = use_signal(|| None::<usize>);
loop {
*last_status.write_unchecked() = Some(Platform::get_status(&client).await);
Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await;
}
});
let mut address_input = use_signal(|| Platform::load_server_url()); let version = option_env!("MUMBLE_WEB2_VERSION");
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 previous_username = Platform::load_username(); let is_override_mode = config
let mut username = use_signal(|| previous_username.unwrap_or(String::new())); .read()
.as_ref()
.is_some_and(|c| !c.any_server);
let do_connect = move |_| { // --- Overrides mode: single preset server, username-only input ---
//let _ = set_default_username(&username.read()); if is_override_mode {
let _ = Platform::set_default_username(&username.read()); let proxy_url = config
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) { .read()
Platform::set_default_server(&address.read()); .as_ref()
} .and_then(|c| c.proxy_url.clone())
net.send(Connect { .unwrap_or_default();
address: address.read().clone(),
username: username.read().clone(), let previous_username = Platform::load_username();
config: config.read().clone().unwrap_or_default(), let mut username = use_signal(|| previous_username.unwrap_or_default());
})
}; let status = &STATE.status;
let status = &STATE.status; let is_connecting = matches!(&*status.read(), Connecting);
let bottom = match &*status.read() {
Disconnected => rsx! { return rsx!(
button {
class: "login_bttn",
onclick: do_connect.clone(),
"Connect"
}
},
Connecting => rsx! {
div { div {
class: "login_bttn", class: "server-list-page",
"Connecting..." h1 {
} "Mumble Web"
}, match version {
Failed(msg) => rsx!( Some(v) => rsx!(div { class: "login_version", "({v})" }),
button { None => rsx!(),
class: "login_bttn", }
onclick: do_connect.clone(), }
"Reconnect" div {
} class: "server-list",
div { div {
class: "login_error", class: "server-card",
"Failed to connect:" img {
pre { class: "server-card__icon",
"{msg}" 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] = [ // --- Normal mode: editable server list ---
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");
rsx!( rsx!(
div { div {
class: "server-list-page", class: "server-list-page",
@@ -791,76 +793,160 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
} }
div { div {
class: "server-list", class: "server-list",
for server in servers { for (idx, server) in servers.read().iter().enumerate() {
div { {
key: "{server.address}", // use the most unique field let address = format!("{}:{}", server.address, server.port);
class: "server-card", let connect_entry = server.clone();
img { rsx!(
class: "server-card__icon", div {
src: asset!("assets/earth-14-svgrepo-com.svg"), key: "{idx}",
alt: "Server icon", class: "server-card",
} img {
div { class: "server-card__icon",
class: "server-card__info", src: asset!("assets/earth-14-svgrepo-com.svg"),
span { class: "server-card__name", "{server.name}" } alt: "Server icon",
span { class: "server-card__address", "{server.address}" } }
} div {
button { class: "server-card__info",
class: "server-card__action", span { class: "server-card__name", "{server.name}" }
onclick: move |_| { /* TODO: initiate connection */ }, span { class: "server-card__address", "{address}" }
img { }
src: asset!("assets/edit-3-svgrepo-com.svg"), ServerPingInfo {
alt: "Connect", 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 { button {
class: "add-server-btn", class: "add-server-btn",
onclick: move |_| show_add_modal.set(true), onclick: move |_| show_add_modal.set(true),
"+ Add Server" "+ Add Server"
} }
// Conditionally render the modal
if *show_add_modal.read() { 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] #[component]
fn AddServerModal(show: Signal<bool>) -> Element { fn ServerPingInfo(address: String, port: u16) -> Element {
rsx!()
}
#[component]
fn AddServerModal(on_save: EventHandler<ServerEntry>, 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! { rsx! {
// Full-screen overlay
div { div {
class: "modal-backdrop", class: "modal-backdrop",
onclick: move |_| show.set(false), onclick: move |_| on_cancel.call(()),
} }
// Centering container
div { div {
class: "modal-container", class: "modal-container",
onclick: move |evt| evt.stop_propagation(), onclick: move |evt| evt.stop_propagation(),
div { div {
class: "modal", class: "modal",
h2 { "Add Server" } h2 { "Add Server" }
div { div {
class: "modal-field", class: "modal-field",
label { "Name" } label { "Name" }
input { input {
r#type: "text", r#type: "text",
placeholder: "My Mumble Server", placeholder: "My Mumble Server",
value: "{name.read()}",
oninput: move |evt| name.set(evt.value().clone()),
} }
} }
div { div {
@@ -869,6 +955,8 @@ fn AddServerModal(show: Signal<bool>) -> Element {
input { input {
r#type: "text", r#type: "text",
placeholder: "mumble.example.com", placeholder: "mumble.example.com",
value: "{address.read()}",
oninput: move |evt| address.set(evt.value().clone()),
} }
} }
div { div {
@@ -877,6 +965,8 @@ fn AddServerModal(show: Signal<bool>) -> Element {
input { input {
r#type: "number", r#type: "number",
placeholder: "64738", placeholder: "64738",
value: "{port.read()}",
oninput: move |evt| port.set(evt.value().clone()),
} }
} }
div { div {
@@ -885,6 +975,8 @@ fn AddServerModal(show: Signal<bool>) -> Element {
input { input {
r#type: "text", r#type: "text",
placeholder: "Nickname", placeholder: "Nickname",
value: "{username.read()}",
oninput: move |evt| username.set(evt.value().clone()),
} }
} }
div { div {
@@ -893,18 +985,135 @@ fn AddServerModal(show: Signal<bool>) -> Element {
input { input {
r#type: "password", r#type: "password",
placeholder: "Password", placeholder: "Password",
value: "{password.read()}",
oninput: move |evt| password.set(evt.value().clone()),
} }
} }
div { div {
class: "modal-actions", class: "modal-actions",
button { button {
class: "modal-btn", class: "modal-btn",
onclick: move |_| show.set(false), onclick: move |_| on_cancel.call(()),
"Cancel" "Cancel"
} }
button { button {
class: "modal-btn modal-btn--primary", 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<ServerEntry>,
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" "Save"
} }
} }