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);
}
/* 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 {
+334 -125
View File
@@ -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<ClientConfig>) -> Element {
pub fn LoginView(config: Resource<ClientConfig>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>);
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::<usize>);
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<ClientConfig>) -> 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<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! {
// 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<bool>) -> 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<bool>) -> 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<bool>) -> 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<bool>) -> 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<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"
}
}