components.rs
1 use dioxus::prelude::*; 2 use dioxus_primitives::alert_dialog::{ 3 AlertDialogAction, AlertDialogActions, AlertDialogCancel, AlertDialogContent, 4 AlertDialogDescription, AlertDialogRoot, AlertDialogTitle, 5 }; 6 use ui::components::button::{Button, ButtonVariant}; 7 use ui::components::input::Input; 8 use ui::components::label::Label; 9 use ui::components::switch::Switch; 10 11 use super::hooks::FlashController; 12 use super::state::*; 13 14 // ───────────────────────────────────────────────────────────────────────────── 15 // ToggleGroup 16 // ───────────────────────────────────────────────────────────────────────────── 17 18 #[component] 19 pub fn ToggleGroup( 20 label: String, 21 options: Vec<(String, String)>, 22 selected: Signal<String>, 23 ) -> Element { 24 let label_slug = label 25 .chars() 26 .map(|character| { 27 if character.is_ascii_alphanumeric() { 28 character.to_ascii_lowercase() 29 } else { 30 '-' 31 } 32 }) 33 .collect::<String>(); 34 let label_element_id = format!("flash-toggle-group-{label_slug}-label"); 35 let label_element_signal = use_signal({ 36 let label_element_id = label_element_id.clone(); 37 move || Some(label_element_id.clone()) 38 }); 39 40 rsx! { 41 div { 42 Label { 43 id: label_element_signal, 44 class: Some("text-muted-foreground text-xs".to_string()), 45 "{label}" 46 } 47 div { 48 class: "flex rounded-lg overflow-hidden border border-border", 49 role: "radiogroup", 50 aria_labelledby: label_element_id.clone(), 51 for (value, display) in options { 52 { 53 let is_selected = *selected.read() == value; 54 let val = value.clone(); 55 rsx! { 56 Button { 57 key: "{value}", 58 variant: ButtonVariant::Ghost, 59 class: { 60 if is_selected { 61 "flex-1 px-1 py-1.5 text-xs bg-primary text-primary-foreground rounded-none border-0 hover:bg-primary/90" 62 } else { 63 "flex-1 px-1 py-1.5 text-xs bg-background text-foreground/70 rounded-none border-0 hover:bg-muted/30" 64 }.to_string() 65 }, 66 role: "radio", 67 aria_checked: is_selected, 68 aria_pressed: Some(is_selected), 69 on_click: move |_| selected.set(val.clone()), 70 "{display}" 71 } 72 } 73 } 74 } 75 } 76 } 77 } 78 } 79 80 // ───────────────────────────────────────────────────────────────────────────── 81 // ConfigSection 82 // ───────────────────────────────────────────────────────────────────────────── 83 84 #[component] 85 pub fn ConfigSection(config: FlashConfig, chip: FlashChipInfo) -> Element { 86 let address_input_label = use_signal(|| Some("flash-address-input".to_string())); 87 let size_options = { 88 let chip_sizes = chip.chip_flash_sizes.read(); 89 let mut opts = vec![ 90 ("keep".to_string(), "keep".to_string()), 91 ("detect".to_string(), "auto".to_string()), 92 ]; 93 if chip_sizes.is_empty() { 94 opts.extend([ 95 ("4MB".to_string(), "4".to_string()), 96 ("8MB".to_string(), "8".to_string()), 97 ("16MB".to_string(), "16".to_string()), 98 ]); 99 } else { 100 for s in chip_sizes.iter() { 101 let display = s.replace("MB", "").replace("KB", ""); 102 opts.push((s.clone(), display)); 103 } 104 } 105 opts 106 }; 107 108 let freq_options = { 109 let chip_freqs = chip.chip_flash_freqs.read(); 110 let mut opts = vec![("keep".to_string(), "keep".to_string())]; 111 if chip_freqs.is_empty() { 112 opts.extend([ 113 ("80m".to_string(), "80".to_string()), 114 ("40m".to_string(), "40".to_string()), 115 ]); 116 } else { 117 for f in chip_freqs.iter() { 118 let display = f.replace("m", ""); 119 opts.push((f.clone(), display)); 120 } 121 } 122 opts 123 }; 124 125 rsx! { 126 div { class: "border border-border rounded-lg p-4", 127 p { class: "text-xs font-medium text-muted-foreground mb-3 uppercase tracking-wider", "Configuration" } 128 129 div { class: "grid grid-cols-2 gap-3 text-xs mb-3", 130 ToggleGroup { label: "Baud (bps)".to_string(), selected: config.baud, 131 options: vec![("115200".into(),"115k".into()),("230400".into(),"230k".into()),("460800".into(),"460k".into()),("921600".into(),"921k".into())] 132 } 133 ToggleGroup { label: "Flash Mode".to_string(), selected: config.mode, 134 options: vec![("keep".into(),"keep".into()),("qio".into(),"QIO".into()),("dio".into(),"DIO".into()),("dout".into(),"DOUT".into())] 135 } 136 ToggleGroup { label: "Frequency (MHz)".to_string(), selected: config.freq, 137 options: freq_options 138 } 139 ToggleGroup { label: "Size (MB)".to_string(), selected: config.size, 140 options: size_options 141 } 142 } 143 144 div { class: "flex items-center gap-4 flex-wrap text-xs", 145 div { 146 Label { 147 for_id: address_input_label, 148 class: Some("text-muted-foreground block mb-1 text-xs".to_string()), 149 "Address" 150 } 151 Input { 152 id: Some("flash-address-input".to_string()), 153 class: Some("bg-background border border-border rounded px-2 py-1 h-auto text-foreground font-mono w-24 focus:ring-0 focus:ring-offset-0".to_string()), 154 input_type: "text".to_string(), 155 aria_label: Some("Flash address".to_string()), 156 value: config.address.read().clone(), 157 on_input: Some(Callback::new(move |e: FormEvent| config.address.set(e.value()))), 158 } 159 } 160 div { class: "flex items-center gap-2 pt-4", 161 Switch { checked: config.compress, on_checked_change: move |val: bool| config.compress.set(val) } 162 span { class: "text-muted-foreground", "Compress" } 163 } 164 div { class: "flex items-center gap-2 pt-4", 165 Switch { checked: config.erase_first, on_checked_change: move |val: bool| config.erase_first.set(val) } 166 span { class: "text-destructive", "Erase first" } 167 } 168 } 169 } 170 } 171 } 172 173 // ───────────────────────────────────────────────────────────────────────────── 174 // WiFiSection 175 // ───────────────────────────────────────────────────────────────────────────── 176 177 #[component] 178 pub fn WiFiSection(config: FlashConfig) -> Element { 179 let wifi_ssid_input_label = use_signal(|| Some("flash-wifi-ssid-input".to_string())); 180 let wifi_password_input_label = use_signal(|| Some("flash-wifi-password-input".to_string())); 181 182 rsx! { 183 div { class: "border border-border rounded-lg p-4", 184 p { class: "text-xs font-medium text-muted-foreground mb-3 uppercase tracking-wider", "WiFi Credentials" } 185 div { class: "flex flex-col gap-2 text-xs", 186 div { 187 Label { 188 for_id: wifi_ssid_input_label, 189 class: Some("text-muted-foreground block mb-1 text-xs".to_string()), 190 "SSID" 191 } 192 Input { 193 id: Some("flash-wifi-ssid-input".to_string()), 194 class: Some("w-full bg-background border border-border rounded px-2 py-1 h-auto text-foreground focus:ring-0 focus:ring-offset-0".to_string()), 195 input_type: "text".to_string(), 196 aria_label: Some("WiFi SSID".to_string()), 197 placeholder: "Leave blank for AP provisioning".to_string(), 198 value: config.wifi_ssid.read().clone(), 199 on_input: Some(Callback::new(move |e: FormEvent| config.wifi_ssid.set(e.value()))), 200 } 201 } 202 div { 203 Label { 204 for_id: wifi_password_input_label, 205 class: Some("text-muted-foreground block mb-1 text-xs".to_string()), 206 "Password" 207 } 208 Input { 209 id: Some("flash-wifi-password-input".to_string()), 210 class: Some("w-full bg-background border border-border rounded px-2 py-1 h-auto text-foreground focus:ring-0 focus:ring-offset-0".to_string()), 211 input_type: "password".to_string(), 212 aria_label: Some("WiFi password".to_string()), 213 placeholder: "WiFi password".to_string(), 214 value: config.wifi_pass.read().clone(), 215 on_input: Some(Callback::new(move |e: FormEvent| config.wifi_pass.set(e.value()))), 216 } 217 } 218 } 219 p { class: "text-[10px] text-muted-foreground/50 mt-2", "Patched into firmware binary at flash time via sentinel slots" } 220 } 221 } 222 } 223 224 // ───────────────────────────────────────────────────────────────────────────── 225 // FirmwareSection 226 // ───────────────────────────────────────────────────────────────────────────── 227 228 #[component] 229 pub fn FirmwareSection( 230 controller: FlashController, 231 config: FlashConfig, 232 chip: FlashChipInfo, 233 ) -> Element { 234 let firmware = controller.firmware; 235 let has_firmware = *firmware.firmware_size.read() > 0; 236 237 rsx! { 238 div { 239 class: "border-2 border-dashed border-border rounded-lg p-6 flex flex-col items-center justify-center hover:border-primary transition-colors cursor-pointer", 240 onclick: move |_| controller.select_firmware(), 241 lucide_dioxus::Upload { class: "w-8 h-8 text-muted-foreground mb-2" } 242 if has_firmware { 243 p { class: "text-sm font-medium text-foreground mb-1", "{firmware.firmware_name}" } 244 p { class: "text-xs text-muted-foreground mb-3", 245 "{*firmware.firmware_size.read() / 1024} KB" 246 } 247 div { class: "text-[10px] text-muted-foreground/70 space-y-0.5 text-center", 248 if !chip.chip_name.read().is_empty() { 249 p { "Target: {chip.chip_name}" } 250 } 251 p { "Address: {config.address}" } 252 p { "Mode: {config.mode} · Freq: {config.freq} · Size: {config.size}" } 253 if *config.compress.read() { 254 p { "Compression enabled" } 255 } 256 } 257 p { class: "text-[10px] text-muted-foreground/50 mt-3", "Click to replace" } 258 } else { 259 p { class: "text-sm text-muted-foreground mb-1", "Select a firmware .bin file" } 260 p { class: "text-[10px] text-muted-foreground/50 mt-1", "Click to browse" } 261 } 262 } 263 } 264 } 265 266 // ───────────────────────────────────────────────────────────────────────────── 267 // ActionRow 268 // ───────────────────────────────────────────────────────────────────────────── 269 270 #[component] 271 pub fn ActionRow(controller: FlashController) -> Element { 272 let has_firmware = *controller.firmware.firmware_size.read() > 0; 273 let flashing = *controller.firmware.flashing.read(); 274 let monitoring = *controller.device.monitor_active.read(); 275 let mut confirm_erase = use_signal(|| false); 276 277 rsx! { 278 div { class: "flex gap-2 mb-3", 279 Button { 280 class: "flex-1 py-2.5 font-semibold hover:bg-muted/50".to_string(), 281 variant: ButtonVariant::Outline, 282 disabled: !has_firmware || flashing, 283 loading: flashing, 284 on_click: move |_| controller.flash(), 285 if !flashing { 286 lucide_dioxus::Zap { class: "w-4 h-4" } 287 } 288 if flashing { "Flashing..." } else { "Flash Firmware" } 289 } 290 Button { 291 class: "flex-1 py-2.5 border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10".to_string(), 292 variant: ButtonVariant::Outline, 293 on_click: move |_| controller.toggle_monitor(), 294 icon_left: rsx! { lucide_dioxus::Terminal { class: "w-3.5 h-3.5" } }, 295 if monitoring { "Stop" } else { "Monitor" } 296 } 297 Button { 298 class: "flex-1 py-2.5 hover:bg-muted/50".to_string(), 299 variant: ButtonVariant::Outline, 300 on_click: move |_| controller.reset(), 301 icon_left: rsx! { lucide_dioxus::RotateCcw { class: "w-3.5 h-3.5" } }, 302 "Reset" 303 } 304 Button { 305 class: "flex-1 py-2.5 border-destructive/50 text-destructive hover:bg-destructive/10".to_string(), 306 variant: ButtonVariant::Destructive, 307 on_click: move |_| confirm_erase.set(true), 308 icon_left: rsx! { lucide_dioxus::Trash2 { class: "w-3.5 h-3.5" } }, 309 "Erase All" 310 } 311 } 312 313 AlertDialogRoot { 314 open: *confirm_erase.read(), 315 on_open_change: move |v: bool| confirm_erase.set(v), 316 class: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm", 317 318 AlertDialogContent { 319 class: "bg-card border border-border rounded-lg shadow-2xl p-6 max-w-sm mx-4", 320 321 AlertDialogTitle { class: "text-lg font-semibold mb-2", "Erase flash memory" } 322 AlertDialogDescription { 323 class: "text-sm text-muted-foreground mb-4", 324 "This will erase the entire flash memory on the device. All firmware and data will be lost. This cannot be undone." 325 } 326 AlertDialogActions { 327 class: "flex justify-end gap-2", 328 AlertDialogCancel { 329 class: "px-3 py-1.5 rounded-lg border border-border text-sm hover:bg-muted/50 transition-colors", 330 "Cancel" 331 } 332 AlertDialogAction { 333 class: "px-3 py-1.5 rounded-lg bg-destructive text-destructive-foreground text-sm hover:bg-destructive/90 transition-colors", 334 on_click: move |_| controller.erase(), 335 "Erase All" 336 } 337 } 338 } 339 } 340 } 341 } 342 343 // ───────────────────────────────────────────────────────────────────────────── 344 // ProgressBar 345 // ───────────────────────────────────────────────────────────────────────────── 346 347 #[component] 348 pub fn ProgressBar(progress: Signal<u8>) -> Element { 349 let val = *progress.read(); 350 if val > 0 && val < 100 { 351 rsx! { 352 div { class: "mb-2", 353 div { class: "w-full h-4 bg-muted rounded-lg overflow-hidden", 354 div { 355 class: "h-full bg-primary text-[10px] text-primary-foreground flex items-center justify-center transition-all duration-300", 356 style: "width: {val}%", 357 "{val}%" 358 } 359 } 360 } 361 } 362 } else { 363 rsx! {} 364 } 365 }