Skip to main content

hadrone_extras/
lib.rs

1use hadrone_core::LayoutItem;
2use serde::{Deserialize, Serialize};
3use std::io::Result;
4#[cfg(not(target_arch = "wasm32"))]
5use std::time::Duration;
6
7#[cfg(not(target_arch = "wasm32"))]
8use std::path::PathBuf;
9
10#[derive(Serialize, Deserialize, Debug, Clone)]
11pub struct LayoutSnapshot {
12    pub version: u32,
13    pub items: Vec<LayoutItem>,
14    pub cols: i32,
15}
16
17pub trait LayoutStorage: Send + Sync {
18    fn save(&self, key: &str, layout: &LayoutSnapshot) -> Result<()>;
19    fn load(&self, key: &str) -> Result<Option<LayoutSnapshot>>;
20}
21
22// --- Native File Storage ---
23
24#[cfg(not(target_arch = "wasm32"))]
25pub struct FileStorage {
26    base_path: PathBuf,
27}
28
29#[cfg(not(target_arch = "wasm32"))]
30impl FileStorage {
31    pub fn new(base_path: impl Into<PathBuf>) -> Self {
32        let path = base_path.into();
33        std::fs::create_dir_all(&path).ok();
34        Self { base_path: path }
35    }
36}
37
38#[cfg(not(target_arch = "wasm32"))]
39impl LayoutStorage for FileStorage {
40    fn save(&self, key: &str, layout: &LayoutSnapshot) -> Result<()> {
41        let path = self.base_path.join(format!("{}.json", key));
42        let data = serde_json::to_string_pretty(layout)?;
43        std::fs::write(path, data)
44    }
45
46    fn load(&self, key: &str) -> Result<Option<LayoutSnapshot>> {
47        let path = self.base_path.join(format!("{}.json", key));
48        if !path.exists() {
49            return Ok(None);
50        }
51        let data = std::fs::read_to_string(path)?;
52        let layout = serde_json::from_str(&data)
53            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
54        Ok(Some(layout))
55    }
56}
57
58// --- Browser Local Storage ---
59
60pub struct BrowserStorage {
61    _prefix: String,
62    #[cfg(target_arch = "wasm32")]
63    fallback: std::sync::Mutex<std::collections::HashMap<String, LayoutSnapshot>>,
64}
65
66impl BrowserStorage {
67    pub fn new(prefix: impl Into<String>) -> Self {
68        Self {
69            _prefix: prefix.into(),
70            #[cfg(target_arch = "wasm32")]
71            fallback: std::sync::Mutex::new(std::collections::HashMap::new()),
72        }
73    }
74
75    #[cfg(target_arch = "wasm32")]
76    fn storage(&self) -> Option<web_sys::Storage> {
77        web_sys::window()?.local_storage().ok()?
78    }
79}
80
81impl LayoutStorage for BrowserStorage {
82    fn save(&self, _key: &str, _layout: &LayoutSnapshot) -> Result<()> {
83        #[cfg(target_arch = "wasm32")]
84        {
85            let storage_key = format!("{}:{}", self._prefix, _key);
86            let data = serde_json::to_string(_layout)
87                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
88
89            if let Some(storage) = self.storage() {
90                if storage.set_item(&storage_key, &data).is_ok() {
91                    return Ok(());
92                }
93            }
94
95            // Fallback
96            self.fallback
97                .lock()
98                .unwrap()
99                .insert(_key.to_string(), _layout.clone());
100        }
101        Ok(())
102    }
103
104    fn load(&self, _key: &str) -> Result<Option<LayoutSnapshot>> {
105        #[cfg(target_arch = "wasm32")]
106        {
107            let storage_key = format!("{}:{}", self._prefix, _key);
108
109            if let Some(storage) = self.storage() {
110                if let Ok(Some(data)) = storage.get_item(&storage_key) {
111                    return serde_json::from_str(&data)
112                        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
113                }
114            }
115
116            // Fallback
117            return Ok(self.fallback.lock().unwrap().get(_key).cloned());
118        }
119
120        #[cfg(not(target_arch = "wasm32"))]
121        Ok(None)
122    }
123}
124
125// --- Responsive Breakpoints ---
126
127#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
128pub struct BreakpointConfig {
129    pub name: String,
130    pub cols: i32,
131    pub min_width: i32,
132    pub margin: (i32, i32),
133    pub row_height: f32,
134}
135
136impl Default for BreakpointConfig {
137    fn default() -> Self {
138        Self {
139            name: "lg".into(),
140            cols: 12,
141            min_width: 1200,
142            margin: (10, 10),
143            row_height: 100.0,
144        }
145    }
146}
147
148// --- Debounced Auto-Save ---
149
150pub async fn debounce_save(
151    storage: &dyn LayoutStorage,
152    key: &str,
153    layout: Vec<LayoutItem>,
154    cols: i32,
155    ms: u64,
156) {
157    #[cfg(not(target_arch = "wasm32"))]
158    tokio::time::sleep(Duration::from_millis(ms)).await;
159
160    #[cfg(target_arch = "wasm32")]
161    gloo_timers::future::TimeoutFuture::new(ms as u32).await;
162
163    let _ = storage.save(
164        key,
165        &LayoutSnapshot {
166            version: 1,
167            items: layout,
168            cols,
169        },
170    );
171}
172
173// --- Responsive Detection ---
174
175#[cfg(feature = "dioxus")]
176pub fn use_responsive_grid(
177    breakpoints: Vec<BreakpointConfig>,
178) -> dioxus::prelude::Signal<BreakpointConfig> {
179    use dioxus::prelude::*;
180
181    let initial_bp = {
182        #[cfg(target_arch = "wasm32")]
183        {
184            if let Some(window) = web_sys::window() {
185                if let Ok(width) = window.inner_width() {
186                    if let Some(w) = width.as_f64() {
187                        let w = w as i32;
188                        let mut best_match = &breakpoints[0];
189                        for bp in &breakpoints {
190                            if w >= bp.min_width && bp.min_width >= best_match.min_width {
191                                best_match = bp;
192                            }
193                        }
194                        best_match.clone()
195                    } else {
196                        breakpoints[0].clone()
197                    }
198                } else {
199                    breakpoints[0].clone()
200                }
201            } else {
202                breakpoints[0].clone()
203            }
204        }
205        #[cfg(not(target_arch = "wasm32"))]
206        {
207            breakpoints.last().unwrap_or(&breakpoints[0]).clone()
208        }
209    };
210
211    let current = use_signal(|| initial_bp);
212
213    #[cfg(target_arch = "wasm32")]
214    {
215        use wasm_bindgen::prelude::*;
216        use web_sys::{ResizeObserver, ResizeObserverEntry};
217
218        use_effect(move || {
219            let breakpoints = breakpoints.clone();
220            let mut current = current;
221
222            let closure = Closure::wrap(Box::new(
223                move |entries: js_sys::Array, _observer: ResizeObserver| {
224                    if let Some(entry) = entries.get(0).dyn_into::<ResizeObserverEntry>().ok() {
225                        let width = entry.content_rect().width() as i32;
226
227                        // Find matching breakpoint
228                        let mut best_match = &breakpoints[0];
229                        for bp in &breakpoints {
230                            if width >= bp.min_width && bp.min_width >= best_match.min_width {
231                                best_match = bp;
232                            }
233                        }
234                        current.set(best_match.clone());
235                    }
236                },
237            )
238                as Box<dyn FnMut(js_sys::Array, ResizeObserver)>);
239
240            let observer = ResizeObserver::new(closure.as_ref().unchecked_ref()).unwrap();
241
242            if let Some(window) = web_sys::window() {
243                if let Some(document) = window.document() {
244                    if let Some(body) = document.body() {
245                        observer.observe(&body);
246                    }
247                }
248            }
249
250            closure.forget();
251        });
252    }
253
254    current
255}