//! Formatting + small browser helpers. use leptos::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; /// A window event listener that is removed when the current owner is disposed. /// /// Leptos' `window_event_listener` returns a `WindowListenerHandle` with no /// `Drop` impl — if the owning component is unmounted without an explicit /// `remove()`, the listener keeps firing on `window` and panics by reading /// the component's now-disposed signals. pub fn owned_window_listener( event: E, cb: impl Fn(E::EventType) + 'static, ) where E::EventType: wasm_bindgen::JsCast, { let handle = window_event_listener(event, cb); on_cleanup(move || handle.remove()); } /// Same as [`owned_window_listener`], but registered in the capture phase, /// so it runs before the bubble-phase window listeners (registration order /// otherwise decides, and that depends on component creation order). pub fn owned_window_listener_capture( event: E, cb: impl Fn(E::EventType) + 'static, ) where E::EventType: wasm_bindgen::JsCast, { let closure = Closure::::new({ move |e: web_sys::Event| cb(e.unchecked_into::()) }); let add_options = web_sys::AddEventListenerOptions::new(); add_options.set_capture(true); let remove_options = web_sys::EventListenerOptions::new(); remove_options.set_capture(true); let name = event.name(); web_sys::window() .expect("window") .add_event_listener_with_callback_and_add_event_listener_options( &name, closure.as_ref().unchecked_ref(), &add_options, ) .expect("failed to add window event listener"); let closure_js = closure.into_js_value(); on_cleanup(move || { let closure: js_sys::Function = closure_js.unchecked_into(); let _ = web_sys::window() .expect("window") .remove_event_listener_with_callback_and_event_listener_options( &name, &closure, &remove_options, ); }); } pub fn format_size(bytes: u64) -> String { const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; let mut v = bytes as f64; let mut i = 0; while v >= 1024.0 && i < UNITS.len() - 1 { v /= 1024.0; i += 1; } if i == 0 { format!("{bytes} B") } else { format!("{v:.1} {}", UNITS[i]) } } /// Format an RFC 3339 timestamp as "YYYY-MM-DD HH:MM" (UTC) for display. /// Falls back to the raw string when it does not look like one. /// /// Every timestamp the server sends is UTC with a `Z` suffix, so the display /// text is a slice of the input. No date parsing is needed. pub fn format_date(rfc3339: &str) -> String { let b = rfc3339.as_bytes(); if b.len() >= 16 && b[10] == b'T' { return format!("{} {}", &rfc3339[..10], &rfc3339[11..16]); } rfc3339.to_string() } pub fn storage_get(key: &str) -> Option { let w = web_sys::window()?; let storage_opt = w.local_storage().ok()?; let storage = storage_opt.as_ref()?; storage.get_item(key).ok().flatten() } pub fn storage_set(key: &str, value: &str) { if let Some(s) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) { let _ = s.set_item(key, value); } } #[derive(Clone, Copy, PartialEq, Debug)] pub enum ViewMode { Grid, List, } impl ViewMode { const KEY: &'static str = "fbng.view"; pub fn load() -> Self { match storage_get(Self::KEY).as_deref() { Some("list") => ViewMode::List, _ => ViewMode::Grid, } } pub fn save(self) { storage_set( Self::KEY, if self == ViewMode::List { "list" } else { "grid" }, ); } } /// Entries rendered at once in a listing, and how many more each click of /// the "show more" row reveals. Rendering a row costs a fixed amount of /// reactive-graph setup, so the first paint stays well under a frame and the /// user pays for more rows only by asking for them. pub const FILE_PAGE: usize = 100; /// Footer under a paged list: where the user is in the list, and a button /// to reveal the next page. /// /// `shown` of `total` is stated plainly rather than only counting what is /// hidden — "Showing 100 of 6027" answers "where am I?", which a bare /// "5927 more…" does not. The button appears only while more items are /// actually retained; past the caller's retain cap the count is all that is /// left to say, so the footer becomes a plain line. /// /// Returns nothing at all when everything is on screen. pub fn page_footer( shown: usize, retained: usize, total: usize, page: usize, reveal: impl Fn() + 'static, ) -> AnyView { if total <= shown { return view! {}.into_view().into_any(); } let label = crate::i18n::t_fmt2( crate::i18n::k::SEARCH_SHOWN_OF, &shown.to_string(), &total.to_string(), ); let pageable = retained.saturating_sub(shown); if pageable == 0 { return view! {
{label}
} .into_view() .into_any(); } let next = pageable.min(page); view! {
{label}
} .into_view() .into_any() } #[cfg(test)] mod tests { use super::*; #[test] fn format_date_drops_seconds_and_zone() { assert_eq!(format_date("2026-09-11T12:34:56Z"), "2026-09-11 12:34"); assert_eq!(format_date("nonsense"), "nonsense"); } }