/ web / src / pages / panels / flash_panel / components.rs
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  }