network_panel.rs
1 use super::{Td, Th}; 2 use crate::api::{WifiNetwork, WirelessStatusData}; 3 use crate::hooks::sleep_ms; 4 use crate::services::{DeviceService, WifiService}; 5 use dioxus::prelude::*; 6 use lucide_dioxus::{LoaderCircle, Radar, Wifi}; 7 use ui::components::button::{Button, ButtonVariant}; 8 use ui::components::input::Input; 9 use ui::components::label::Label; 10 use ui::components::toast::use_toast; 11 12 #[component] 13 pub fn NetworkPanel( 14 device_url: Signal<String>, 15 wireless: Signal<Option<WirelessStatusData>>, 16 networks: Signal<Vec<WifiNetwork>>, 17 scanning: Signal<bool>, 18 connecting: Signal<bool>, 19 ssid_input: Signal<String>, 20 password_input: Signal<String>, 21 ) -> Element { 22 let toasts = use_toast(); 23 let mut selected_index = use_signal(|| None::<usize>); 24 let ssid_input_label = use_signal(|| Some("network-ssid-input".to_string())); 25 let password_input_label = use_signal(|| Some("network-password-input".to_string())); 26 27 let wireless_data = wireless.read(); 28 29 rsx! { 30 section { id: "network-section", class: "flex flex-col h-full", 31 div { class: "mb-3", 32 Button { 33 class: "gold-button-outline text-sm w-full justify-center".to_string(), 34 variant: ButtonVariant::Outline, 35 disabled: *scanning.read(), 36 loading: *scanning.read(), 37 on_click: move |_| { 38 scanning.set(true); 39 let url = device_url.read().clone(); 40 spawn(async move { 41 match WifiService::scan(&url).await { 42 Ok(response) => { 43 let count = response.data.networks.len(); 44 networks.set(response.data.networks); 45 toasts.success(format!("Found {count} network(s)"), None); 46 } 47 Err(error) => toasts.error(format!("Scan failed: {error}"), None), 48 } 49 scanning.set(false); 50 }); 51 }, 52 if !*scanning.read() { 53 Radar { class: "w-4 h-4" } 54 } 55 if *scanning.read() { "Scanning..." } else { "Scan" } 56 } 57 } 58 59 // WiFi connect form 60 form { 61 class: "mt-2 flex flex-col lg:flex-row lg:items-end gap-2 mb-3", 62 onsubmit: move |form_event| { 63 form_event.prevent_default(); 64 let ssid = ssid_input.read().clone(); 65 let password = password_input.read().clone(); 66 if ssid.is_empty() { return; } 67 connecting.set(true); 68 let url = device_url.read().clone(); 69 spawn(async move { 70 match WifiService::connect(&url, &ssid, &password).await { 71 Ok(_) => { 72 toasts.success(format!("Connecting to {ssid}..."), None); 73 sleep_ms(3000).await; 74 if let Ok(response) = WifiService::get_status(&url).await { 75 wireless.set(Some(response.data)); 76 } 77 } 78 Err(error) => toasts.error(format!("Connect failed: {error}"), None), 79 } 80 connecting.set(false); 81 }); 82 }, 83 div { class: "min-w-0 flex-1", 84 Label { 85 for_id: ssid_input_label, 86 class: Some("text-xs uppercase tracking-wider text-muted-foreground mb-2".to_string()), 87 "SSID" 88 } 89 Input { 90 id: Some("network-ssid-input".to_string()), 91 class: Some("gold-input w-full px-3 py-2 text-sm".to_string()), 92 input_type: "text".to_string(), 93 aria_label: Some("SSID".to_string()), 94 placeholder: "SSID".to_string(), 95 value: ssid_input.read().clone(), 96 on_input: Some(Callback::new(move |event: FormEvent| ssid_input.set(event.value()))), 97 } 98 } 99 div { class: "min-w-0 flex-1", 100 Label { 101 for_id: password_input_label, 102 class: Some("text-xs uppercase tracking-wider text-muted-foreground mb-2".to_string()), 103 "Password" 104 } 105 Input { 106 id: Some("network-password-input".to_string()), 107 class: Some("gold-input w-full px-3 py-2 text-sm".to_string()), 108 input_type: "password".to_string(), 109 aria_label: Some("Password".to_string()), 110 placeholder: "Password (blank for open)".to_string(), 111 value: password_input.read().clone(), 112 on_input: Some(Callback::new(move |event: FormEvent| password_input.set(event.value()))), 113 } 114 } 115 div { class: "lg:w-auto lg:flex-none", 116 Button { 117 class: "gold-button-outline text-sm whitespace-nowrap".to_string(), 118 variant: ButtonVariant::Outline, 119 button_type: "submit".to_string(), 120 disabled: ssid_input.read().is_empty() || *connecting.read(), 121 loading: *connecting.read(), 122 if !*connecting.read() { 123 Wifi { class: "w-4 h-4" } 124 } 125 if *connecting.read() { "Connecting..." } else { "Connect" } 126 } 127 } 128 } 129 130 // Scan results table 131 div { 132 class: "overflow-auto flex-1 border border-border rounded-lg outline-none", 133 tabindex: "0", 134 onkeydown: move |e: KeyboardEvent| { 135 let count = networks.read().len(); 136 if count == 0 { return; } 137 match e.key() { 138 Key::ArrowDown => { 139 e.prevent_default(); 140 let next = match *selected_index.read() { 141 Some(i) => (i + 1) % count, 142 None => 0, 143 }; 144 selected_index.set(Some(next)); 145 } 146 Key::ArrowUp => { 147 e.prevent_default(); 148 let next = match *selected_index.read() { 149 Some(0) | None => count - 1, 150 Some(i) => i - 1, 151 }; 152 selected_index.set(Some(next)); 153 } 154 Key::Enter => { 155 if let Some(i) = *selected_index.read() { 156 if let Some(network) = networks.read().get(i) { 157 ssid_input.set(network.ssid.clone()); 158 } 159 } 160 } 161 Key::Escape => { 162 selected_index.set(None); 163 } 164 _ => {} 165 } 166 }, 167 table { class: "w-full border-collapse min-w-[420px]", 168 thead { 169 tr { 170 Th { "SSID" } 171 Th { "RSSI" } 172 Th { "CHANNEL" } 173 Th { "SECURITY" } 174 } 175 } 176 tbody { 177 if networks.read().is_empty() { 178 tr { 179 td { colspan: "4", class: "text-muted-foreground text-sm px-3 py-2 border-b border-border", 180 "Run a scan to list nearby WiFi networks." 181 } 182 } 183 } 184 for (network_index, network) in networks.read().iter().enumerate() { 185 { 186 let is_connected_network = wireless_data.as_ref() 187 .is_some_and(|wireless_info| wireless_info.sta_ssid == network.ssid && !network.ssid.is_empty()); 188 let is_selected = *selected_index.read() == Some(network_index); 189 let row_class = if is_connected_network { 190 "border-b border-border bg-emerald-500/10" 191 } else if is_selected { 192 "border-b border-border bg-primary/10" 193 } else { 194 "border-b border-border hover:bg-muted/30 transition-colors" 195 }; 196 let ssid_display = if network.ssid.is_empty() { "(hidden)".to_string() } else { network.ssid.clone() }; 197 let ssid_for_click = network.ssid.clone(); 198 let ssid_class = if is_connected_network { 199 "border-0 bg-transparent p-0 cursor-pointer transition-colors hover:text-accent text-emerald-400 font-semibold" 200 } else if network.ssid.is_empty() { 201 "border-0 bg-transparent p-0 cursor-pointer transition-colors hover:text-accent text-muted-foreground italic" 202 } else { 203 "border-0 bg-transparent p-0 cursor-pointer transition-colors hover:text-accent text-primary underline" 204 }; 205 206 rsx! { 207 tr { key: "{network_index}-{ssid_display}", class: "{row_class}", 208 td { class: "px-3 py-2 text-sm", 209 Button { 210 class: ssid_class.to_string(), 211 variant: ButtonVariant::Ghost, 212 on_click: move |_| ssid_input.set(ssid_for_click.clone()), 213 "{ssid_display}" 214 } 215 if is_connected_network { 216 span { class: "ml-2 text-xs bg-emerald-500/20 text-emerald-400 rounded px-1.5 py-0.5", 217 "{wireless_data.as_ref().map(|wireless_info| wireless_info.sta_ipv4.as_str()).unwrap_or(\"\")}" 218 } 219 } 220 } 221 Td { class: "font-mono", "{network.rssi}" } 222 Td { class: "font-mono", "{network.channel}" } 223 Td { class: "text-muted-foreground", "{network.encryption}" } 224 } 225 } 226 } 227 } 228 } 229 } 230 } 231 } 232 } 233 }