/ web / src / pages / panels / sleep_panel.rs
sleep_panel.rs
  1  use crate::api::DeviceStatusData;
  2  use crate::services::DeviceService;
  3  use dioxus::prelude::*;
  4  use lucide_dioxus::Moon;
  5  use ui::components::button::{Button, ButtonVariant};
  6  use ui::components::switch::Switch;
  7  use ui::components::toast::use_toast;
  8  use super::shared_ui::StatusBadge;
  9  
 10  const PRESETS: &[(u64, &str)] = &[
 11      (60, "1m"),
 12      (300, "5m"),
 13      (900, "15m"),
 14      (3600, "1h"),
 15  ];
 16  
 17  #[component]
 18  pub fn SleepPanel(
 19      device_url: Signal<String>,
 20      status: Signal<Option<DeviceStatusData>>,
 21  ) -> Element {
 22      let toasts = use_toast();
 23      let mut enabled = use_signal(|| false);
 24      let mut duration = use_signal(|| 300u64);
 25      let mut custom_active = use_signal(|| false);
 26      let mut custom_h = use_signal(String::new);
 27      let mut custom_m = use_signal(String::new);
 28      let mut custom_s = use_signal(String::new);
 29      let mut dirty = use_signal(|| false);
 30      let mut saving = use_signal(|| false);
 31  
 32      let mut sync_hms_from_duration = move |secs: u64| {
 33          custom_h.set((secs / 3600).to_string());
 34          custom_m.set(((secs % 3600) / 60).to_string());
 35          custom_s.set((secs % 60).to_string());
 36      };
 37  
 38      let mut recompute_duration = move || {
 39          let h = custom_h.read().trim().parse::<u64>().unwrap_or(0);
 40          let m = custom_m.read().trim().parse::<u64>().unwrap_or(0);
 41          let s = custom_s.read().trim().parse::<u64>().unwrap_or(0);
 42          duration.set(h * 3600 + m * 60 + s);
 43      };
 44  
 45      use_effect(move || {
 46          let snapshot = status.read().clone();
 47          if *dirty.read() {
 48              return;
 49          }
 50  
 51          if let Some(snapshot) = snapshot {
 52              enabled.set(snapshot.sleep.enabled);
 53              let secs = snapshot.sleep.default_duration_seconds;
 54              duration.set(secs);
 55              if PRESETS.iter().any(|(s, _)| *s == secs) {
 56                  custom_active.set(false);
 57              } else {
 58                  custom_active.set(true);
 59                  sync_hms_from_duration(secs);
 60              }
 61          }
 62      });
 63  
 64      let snapshot = status.read().clone();
 65      let is_custom = *custom_active.read();
 66  
 67      rsx! {
 68          section { id: "sleep-panel", class: "panel-shell-strong p-4",
 69              div { class: "flex items-center justify-between gap-3",
 70                  div { class: "flex items-center gap-3",
 71                      h2 { class: "text-xl font-semibold", "Deep Sleep" }
 72                      if let Some(snapshot) = snapshot.as_ref() {
 73                          StatusBadge {
 74                              icon: rsx! { span { class: "block h-2 w-2 rounded-full bg-amber-400" } },
 75                              value: snapshot.sleep.wake_cause.clone()
 76                          }
 77                      }
 78                  }
 79                  div { class: "flex items-center gap-3",
 80                      div { class: "flex items-center rounded-lg overflow-hidden border border-border",
 81                          for &(secs, label) in PRESETS {
 82                              {
 83                                  let is_active = !is_custom && *duration.read() == secs;
 84                                  rsx! {
 85                                      button {
 86                                          key: "{secs}",
 87                                          class: if is_active {
 88                                              "px-2.5 py-1.5 text-xs font-mono bg-primary text-primary-foreground"
 89                                          } else {
 90                                              "px-2.5 py-1.5 text-xs font-mono bg-background text-foreground/70 hover:bg-muted/30"
 91                                          },
 92                                          onclick: move |_| {
 93                                              duration.set(secs);
 94                                              custom_active.set(false);
 95                                              dirty.set(true);
 96                                          },
 97                                          "{label}"
 98                                      }
 99                                  }
100                              }
101                          }
102                          button {
103                              class: if is_custom {
104                                  "px-2.5 py-1.5 text-xs font-mono bg-primary text-primary-foreground"
105                              } else {
106                                  "px-2.5 py-1.5 text-xs font-mono bg-background text-foreground/70 hover:bg-muted/30"
107                              },
108                              onclick: move |_| {
109                                  custom_active.set(true);
110                                  dirty.set(true);
111                                  sync_hms_from_duration(*duration.read());
112                              },
113                              "custom"
114                          }
115                      }
116                      if is_custom {
117                          div { class: "flex items-center",
118                              input {
119                                  r#type: "number",
120                                  class: "gold-input w-7 px-0 py-1 text-xs font-mono text-center bg-background border border-border rounded-l",
121                                  aria_label: "Hours",
122                                  placeholder: "0",
123                                  value: custom_h.read().clone(),
124                                  oninput: move |e| {
125                                      custom_h.set(e.value());
126                                      recompute_duration();
127                                      dirty.set(true);
128                                  },
129                              }
130                              span { class: "text-[10px] text-muted-foreground px-0.5", "h" }
131                              input {
132                                  r#type: "number",
133                                  class: "gold-input w-7 px-0 py-1 text-xs font-mono text-center bg-background border border-border",
134                                  aria_label: "Minutes",
135                                  placeholder: "0",
136                                  value: custom_m.read().clone(),
137                                  oninput: move |e| {
138                                      custom_m.set(e.value());
139                                      recompute_duration();
140                                      dirty.set(true);
141                                  },
142                              }
143                              span { class: "text-[10px] text-muted-foreground px-0.5", "m" }
144                              input {
145                                  r#type: "number",
146                                  class: "gold-input w-7 px-0 py-1 text-xs font-mono text-center bg-background border border-border rounded-r",
147                                  aria_label: "Seconds",
148                                  placeholder: "0",
149                                  value: custom_s.read().clone(),
150                                  oninput: move |e| {
151                                      custom_s.set(e.value());
152                                      recompute_duration();
153                                      dirty.set(true);
154                                  },
155                              }
156                              span { class: "text-[10px] text-muted-foreground pl-0.5", "s" }
157                          }
158                      }
159                      Switch {
160                          checked: enabled,
161                          on_checked_change: move |value: bool| {
162                              let duration_seconds = *duration.read();
163                              if duration_seconds == 0 {
164                                  toasts.error("Sleep duration must be greater than 0".to_string(), None);
165                                  return;
166                              }
167                              let url = device_url.read().clone();
168                              enabled.set(value);
169                              dirty.set(true);
170                              saving.set(true);
171                              spawn(async move {
172                                  match DeviceService::update_sleep_config(&url, value, duration_seconds).await {
173                                      Ok(response) if response.ok => {
174                                          if let Ok(envelope) = DeviceService::get_status(&url).await {
175                                              status.set(Some(envelope.data));
176                                          }
177                                          dirty.set(false);
178                                          toasts.success("Deep sleep configuration saved".to_string(), None);
179                                      }
180                                      Ok(_) => toasts.error("Sleep config update failed".to_string(), None),
181                                      Err(error) => toasts.error(format!("Sleep config update failed: {error}"), None),
182                                  }
183                                  saving.set(false);
184                              });
185                          }
186                      }
187                      Button {
188                          class: "px-3 py-1.5 rounded-lg text-foreground flex items-center gap-2 transition-colors hover:bg-muted/70 text-xs".to_string(),
189                          variant: ButtonVariant::Outline,
190                          on_click: move |_| {
191                              let dur = *duration.read();
192                              if dur == 0 {
193                                  toasts.error("Set a duration first".to_string(), None);
194                                  return;
195                              }
196                              let url = device_url.read().clone();
197                              saving.set(true);
198                              spawn(async move {
199                                  match DeviceService::trigger_sleep(&url).await {
200                                      Ok(response) if response.ok => {
201                                          toasts.success("Device entering deep sleep".to_string(), None);
202                                      }
203                                      Ok(_) => toasts.error("Sleep trigger failed".to_string(), None),
204                                      Err(error) => toasts.error(format!("Sleep trigger failed: {error}"), None),
205                                  }
206                                  saving.set(false);
207                              });
208                          },
209                          Moon { class: "w-3.5 h-3.5" }
210                          "Sleep"
211                      }
212                  }
213              }
214          }
215      }
216  }