From 5156338eb3e6431ed320bcd3adc84e264a287cff Mon Sep 17 00:00:00 2001 From: restitux Date: Tue, 5 May 2026 05:39:48 +0000 Subject: [PATCH] gui: add server add and edit modals --- gui/assets/delete-2-svgrepo-com.svg | 8 + gui/assets/edit-3-svgrepo-com.svg | 5 + gui/assets/main.scss | 185 +++++++++++++++++ gui/src/main.rs | 298 +++++++++++++++++++++++++++- 4 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 gui/assets/delete-2-svgrepo-com.svg create mode 100644 gui/assets/edit-3-svgrepo-com.svg 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/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 b31e9ed..439f7b7 100644 --- a/gui/assets/main.scss +++ b/gui/assets/main.scss @@ -538,6 +538,173 @@ a:visited { 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 { @@ -592,3 +759,21 @@ a:visited { 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 d2e6dfe..d711f44 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -497,7 +497,9 @@ pub fn ServerView(overrides: Resource) -> Element { #[component] fn ServerCard( + idx: usize, server: ServerEntry, + editing_index: Signal>, overrides: Resource, ) -> Element { let net: Coroutine = use_coroutine_handle(); @@ -522,6 +524,14 @@ fn ServerCard( 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: { @@ -640,13 +650,16 @@ fn OverrideLoginView(overrides: Resource) -> Element { #[component] pub fn LoginView(overrides: Resource) -> Element { let user_config = use_context::(); + let net: Coroutine = use_coroutine_handle(); let state = use_context::(); - let servers = use_signal(|| { + 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() @@ -674,7 +687,9 @@ pub fn LoginView(overrides: Resource) -> Element { for (idx, server) in servers.read().iter().enumerate() { ServerCard { key: "{idx}", + idx, server: server.clone(), + editing_index, overrides, } } @@ -692,6 +707,65 @@ pub fn LoginView(overrides: Resource) -> Element { ), _ => rsx!(), } + button { + class: "add-server-btn", + onclick: move |_| show_add_modal.set(true), + "+ Add Server" + } + + ServerModals { servers, show_add_modal, editing_index } + } + ) +} + +#[component] +fn ServerModals( + servers: Signal>, + show_add_modal: Signal, + editing_index: Signal>, +) -> Element { + let user_config = use_context::(); + + rsx!( + if *show_add_modal.read() { + { + let user_config = user_config.clone(); + let mut servers = servers; + let mut show_add_modal = show_add_modal; + 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(); + let mut servers = servers; + let mut editing_index = editing_index; + 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), + }) + } + } } ) } @@ -738,6 +812,228 @@ fn ServerPingInfo(address: String, port: u16) -> Element { } } +#[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] pub fn app() -> Element { static STYLE: Asset = asset!("/assets/main.scss");