util.rs
⎇
Raw
1//! Formatting + small browser helpers.
2
3use leptos::prelude::*;
4use wasm_bindgen::JsCast;
5use wasm_bindgen::closure::Closure;
6
7/// A window event listener that is removed when the current owner is disposed.
8///
9/// Leptos' `window_event_listener` returns a `WindowListenerHandle` with no
10/// `Drop` impl — if the owning component is unmounted without an explicit
11/// `remove()`, the listener keeps firing on `window` and panics by reading
12/// the component's now-disposed signals.
13pub fn owned_window_listener<E: leptos::ev::EventDescriptor + 'static>(
14 event: E,
15 cb: impl Fn(E::EventType) + 'static,
16) where
17 E::EventType: wasm_bindgen::JsCast,
18{
19 let handle = window_event_listener(event, cb);
20 on_cleanup(move || handle.remove());
21}
22
23/// Same as [`owned_window_listener`], but registered in the capture phase,
24/// so it runs before the bubble-phase window listeners (registration order
25/// otherwise decides, and that depends on component creation order).
26pub fn owned_window_listener_capture<E: leptos::ev::EventDescriptor + 'static>(
27 event: E,
28 cb: impl Fn(E::EventType) + 'static,
29) where
30 E::EventType: wasm_bindgen::JsCast,
31{
32 let closure = Closure::<dyn FnMut(web_sys::Event)>::new({
33 move |e: web_sys::Event| cb(e.unchecked_into::<E::EventType>())
34 });
35 let add_options = web_sys::AddEventListenerOptions::new();
36 add_options.set_capture(true);
37 let remove_options = web_sys::EventListenerOptions::new();
38 remove_options.set_capture(true);
39 let name = event.name();
40 web_sys::window()
41 .expect("window")
42 .add_event_listener_with_callback_and_add_event_listener_options(
43 &name,
44 closure.as_ref().unchecked_ref(),
45 &add_options,
46 )
47 .expect("failed to add window event listener");
48 let closure_js = closure.into_js_value();
49 on_cleanup(move || {
50 let closure: js_sys::Function = closure_js.unchecked_into();
51 let _ = web_sys::window()
52 .expect("window")
53 .remove_event_listener_with_callback_and_event_listener_options(
54 &name,
55 &closure,
56 &remove_options,
57 );
58 });
59}
60
61pub fn format_size(bytes: u64) -> String {
62 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
63 let mut v = bytes as f64;
64 let mut i = 0;
65 while v >= 1024.0 && i < UNITS.len() - 1 {
66 v /= 1024.0;
67 i += 1;
68 }
69 if i == 0 {
70 format!("{bytes} B")
71 } else {
72 format!("{v:.1} {}", UNITS[i])
73 }
74}
75
76/// Format an RFC 3339 timestamp as "YYYY-MM-DD HH:MM" (UTC) for display.
77/// Falls back to the raw string when it does not look like one.
78///
79/// Every timestamp the server sends is UTC with a `Z` suffix, so the display
80/// text is a slice of the input. No date parsing is needed.
81pub fn format_date(rfc3339: &str) -> String {
82 let b = rfc3339.as_bytes();
83 if b.len() >= 16 && b[10] == b'T' {
84 return format!("{} {}", &rfc3339[..10], &rfc3339[11..16]);
85 }
86 rfc3339.to_string()
87}
88
89pub fn storage_get(key: &str) -> Option<String> {
90 let w = web_sys::window()?;
91 let storage_opt = w.local_storage().ok()?;
92 let storage = storage_opt.as_ref()?;
93 storage.get_item(key).ok().flatten()
94}
95
96pub fn storage_set(key: &str, value: &str) {
97 if let Some(s) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) {
98 let _ = s.set_item(key, value);
99 }
100}
101
102#[derive(Clone, Copy, PartialEq, Debug)]
103pub enum ViewMode {
104 Grid,
105 List,
106}
107
108impl ViewMode {
109 const KEY: &'static str = "fbng.view";
110
111 pub fn load() -> Self {
112 match storage_get(Self::KEY).as_deref() {
113 Some("list") => ViewMode::List,
114 _ => ViewMode::Grid,
115 }
116 }
117
118 pub fn save(self) {
119 storage_set(
120 Self::KEY,
121 if self == ViewMode::List {
122 "list"
123 } else {
124 "grid"
125 },
126 );
127 }
128}
129
130/// Entries rendered at once in a listing, and how many more each click of
131/// the "show more" row reveals. Rendering a row costs a fixed amount of
132/// reactive-graph setup, so the first paint stays well under a frame and the
133/// user pays for more rows only by asking for them.
134pub const FILE_PAGE: usize = 100;
135
136/// Footer under a paged list: where the user is in the list, and a button
137/// to reveal the next page.
138///
139/// `shown` of `total` is stated plainly rather than only counting what is
140/// hidden — "Showing 100 of 6027" answers "where am I?", which a bare
141/// "5927 more…" does not. The button appears only while more items are
142/// actually retained; past the caller's retain cap the count is all that is
143/// left to say, so the footer becomes a plain line.
144///
145/// Returns nothing at all when everything is on screen.
146pub fn page_footer(
147 shown: usize,
148 retained: usize,
149 total: usize,
150 page: usize,
151 reveal: impl Fn() + 'static,
152) -> AnyView {
153 if total <= shown {
154 return view! {}.into_view().into_any();
155 }
156 let label = crate::i18n::t_fmt2(
157 crate::i18n::k::SEARCH_SHOWN_OF,
158 &shown.to_string(),
159 &total.to_string(),
160 );
161 let pageable = retained.saturating_sub(shown);
162 if pageable == 0 {
163 return view! { <div class="page-foot">{label}</div> }
164 .into_view()
165 .into_any();
166 }
167 let next = pageable.min(page);
168 view! {
169 <div class="page-foot">
170 <span class="pf-count">{label}</span>
171 <button class="pf-more" on:click=move |_| reveal()>
172 {crate::i18n::t_fmt(crate::i18n::k::SEARCH_SHOW_MORE, &next.to_string())}
173 </button>
174 </div>
175 }
176 .into_view()
177 .into_any()
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn format_date_drops_seconds_and_zone() {
186 assert_eq!(format_date("2026-09-11T12:34:56Z"), "2026-09-11 12:34");
187 assert_eq!(format_date("nonsense"), "nonsense");
188 }
189}
190