diff --git a/gui/assets/main.scss b/gui/assets/main.scss index 9fca136..20a0040 100644 --- a/gui/assets/main.scss +++ b/gui/assets/main.scss @@ -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 { diff --git a/gui/src/app.rs b/gui/src/app.rs index de63e4c..7613fc7 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -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) -> Element { pub fn LoginView(config: Resource) -> Element { let net: Coroutine = use_coroutine_handle(); - let last_status = use_signal(|| None::>); - 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::); - 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) -> 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) -> Element { +fn ServerPingInfo(address: String, port: u16) -> Element { + rsx!() +} + +#[component] +fn AddServerModal(on_save: EventHandler, 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) -> 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) -> 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) -> 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) -> 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, + 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" } }