From d106df83f8cb3791ef03af7f9d3f0dcf2676de0c Mon Sep 17 00:00:00 2001 From: restitux Date: Tue, 5 May 2026 05:39:47 +0000 Subject: [PATCH] gui: new login screen --- 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 | 645 +++++++++++++++++++------ 6 files changed, 991 insertions(+), 151 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/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 f246ad2..0625106 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,528 @@ pub fn ServerView(overrides: Resource) -> Element { } #[component] -pub fn LoginView(overrides: Resource) -> Element { +fn ServerCard( + idx: usize, + server: ServerEntry, + editing_index: Signal>, + 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 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", + 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", + } } } - }); + ) +} + +#[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 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 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}", + idx, + server: server.clone(), + editing_index, + overrides, + } + } + } + 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]