//! Formatting + small browser helpers. use leptos::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use crate::components::toast::{ToastMsg, show, show_error}; use crate::i18n; /// Write `text` to the clipboard and confirm it with `msg`. /// /// A rejected write is ignored, so the toast appears even when it failed. pub fn copy_to_clipboard(text: &str, msg: &str, toast: ToastMsg) { let Some(win) = web_sys::window() else { show_error(toast, i18n::t(i18n::k::COPY_MANUAL).to_string()); return; }; let cb = win.navigator().clipboard(); let (text, msg) = (text.to_string(), msg.to_string()); wasm_bindgen_futures::spawn_local(async move { let _ = wasm_bindgen_futures::JsFuture::from(cb.write_text(&text)).await; show(toast, msg); }); } /// 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()); } /// A touch long press (500 ms without lifting or moving) that stands in for /// a right click. Android fires `contextmenu` on a long press by itself; /// iOS Safari does not, so the pointer events are watched directly. /// /// One instance per element. Wire `down`, `up`, `mv` to `pointerdown`, /// `pointerup` + `pointercancel` + `pointerleave`, and `pointermove`. The /// element's `click` handler must call [`LongPress::swallow_click`] first: /// the browser still sends the click of the finger lifting after the menu /// opened, and that click would close the menu again. #[derive(Clone, Copy)] pub struct LongPress { inner: StoredValue, } #[derive(Default)] struct LongPressState { timer: Option, start: (i32, i32), fired: bool, } impl LongPress { pub const DELAY_MS: u32 = 500; /// Finger travel that turns the press into a scroll. const SLOP_PX: i32 = 10; pub fn new() -> Self { Self { inner: StoredValue::new_local(LongPressState::default()), } } /// `pointerdown`: arm the timer for touch pointers; `on_fire` gets the /// client coordinates. Stops propagation so a nested element and its /// container do not both arm. pub fn down(&self, ev: &web_sys::PointerEvent, on_fire: impl Fn(i32, i32) + 'static) { if ev.pointer_type() != "touch" { return; } ev.stop_propagation(); let (x, y) = (ev.client_x(), ev.client_y()); let inner = self.inner; let timer = gloo_timers::callback::Timeout::new(Self::DELAY_MS, move || { inner.update_value(|st| { st.timer = None; st.fired = true; }); on_fire(x, y); }); self.inner.update_value(|st| { st.timer = Some(timer); st.start = (x, y); st.fired = false; }); } /// `pointerup`, `pointercancel`, `pointerleave`: disarm. pub fn up(&self) { self.inner.update_value(|st| st.timer = None); } /// `pointermove`: disarm once the finger travels; that is a scroll. pub fn mv(&self, ev: &web_sys::PointerEvent) { self.inner.update_value(|st| { if st.timer.is_none() { return; } let (sx, sy) = st.start; if (ev.client_x() - sx).abs() > Self::SLOP_PX || (ev.client_y() - sy).abs() > Self::SLOP_PX { st.timer = None; } }); } /// True once, for the click that follows a fired press. The caller /// stops that click instead of handling it. pub fn swallow_click(&self) -> bool { self.inner .try_update_value(|st| std::mem::take(&mut st.fired)) .unwrap_or(false) } } /// 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() } /// Format a JS date in the UI language with `Intl` options such as /// `[("month", "long")]`. Local time, unless the options name a `timeZone`. pub fn intl(date: &js_sys::Date, options: &[(&str, &str)]) -> String { let opts = js_sys::Object::new(); for (k, v) in options { let _ = js_sys::Reflect::set(&opts, &(*k).into(), &(*v).into()); } date.to_locale_string(i18n::current_untracked().code(), &opts) .into() } /// An RFC 3339 timestamp as local date and time, e.g. "5 Oct 2026, 10:00". pub fn local_date_time(rfc3339: &str) -> String { let d = js_sys::Date::new(&rfc3339.into()); if d.get_time().is_nan() { return rfc3339.to_string(); } intl(&d, &[("dateStyle", "medium"), ("timeStyle", "short")]) } 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 = "dovenest.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" }, ); } } pub use api_types::SortKey; /// A sort key plus its direction, persisted like [`ViewMode`]. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub struct SortSpec { pub key: SortKey, pub asc: bool, } impl Default for SortSpec { fn default() -> Self { Self { key: SortKey::Name, asc: true, } } } impl SortSpec { const KEY: &'static str = "dovenest.sort"; pub fn load() -> Self { let raw = storage_get(Self::KEY).unwrap_or_default(); let (key, dir) = raw.split_once(':').unwrap_or(("", "")); match SortKey::parse(key) { Some(key) => Self { key, asc: dir != "desc", }, None => Self::default(), } } pub fn save(self) { let dir = if self.asc { "asc" } else { "desc" }; storage_set(Self::KEY, &format!("{}:{dir}", self.key.as_str())); } } /// Rows rendered at once: the search's "show more" step, and the folder /// listing's default page size. 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_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_any(); } let next = pageable.min(page); view! {
{label}
} .into_any() } /// Page sizes the folder listing offers. pub const PAGE_SIZES: [usize; 4] = [50, 100, 200, 500]; const PAGE_SIZE_KEY: &str = "dovenest.page_size"; pub fn load_page_size() -> usize { storage_get(PAGE_SIZE_KEY) .and_then(|v| v.parse().ok()) .filter(|n| PAGE_SIZES.contains(n)) .unwrap_or(FILE_PAGE) } pub fn save_page_size(n: usize) { storage_set(PAGE_SIZE_KEY, &n.to_string()); } /// The page buttons to show, 0-based: the first, the last, and two on each /// side of `cur`. `None` is a gap. pub fn page_numbers(cur: usize, pages: usize) -> Vec> { let mut out = Vec::new(); for p in 0..pages { if p == 0 || p + 1 == pages || p.abs_diff(cur) <= 2 { out.push(Some(p)); } else if out.last() != Some(&None) { out.push(None); } } out } /// Footer under a server-paged list. Empty when the list fits the smallest /// page size, since then no control does anything. pub fn pager( offset: usize, shown: usize, total: usize, size: usize, go: Callback, set_size: Callback, ) -> AnyView { use crate::i18n::k; if total <= PAGE_SIZES[0] { return view! {}.into_any(); } let cur = offset / size; let pages = total.div_ceil(size); let last = pages - 1; let label = crate::i18n::t_fmt2( k::SEARCH_SHOWN_OF, &format!("{}–{}", offset + 1, offset + shown), &total.to_string(), ); let nav = move |glyph: &'static str, title: &'static str, to: usize, off: bool| { view! { } }; let numbers = page_numbers(cur, pages) .into_iter() .map(|p| match p { Some(p) => view! { } .into_any(), None => view! { "…" }.into_any(), }) .collect_view(); view! {
{label}
} .into_any() } #[cfg(test)] mod tests { use super::*; #[test] fn page_numbers_keep_the_ends_and_the_neighbours() { let n = |cur, pages| page_numbers(cur, pages); assert_eq!(n(0, 1), [Some(0)]); assert_eq!(n(2, 5), [Some(0), Some(1), Some(2), Some(3), Some(4)]); assert_eq!(n(0, 10), [Some(0), Some(1), Some(2), None, Some(9)]); assert_eq!( n(5, 10), [ Some(0), None, Some(3), Some(4), Some(5), Some(6), Some(7), None, Some(9) ] ); } #[test] fn sort_spec_round_trips_through_its_stored_form() { // No localStorage in the test runner, so only the parse half runs // here. `load` falls back to the default. assert_eq!(SortKey::parse("size"), Some(SortKey::Size)); assert_eq!(SortKey::parse("bogus"), None); assert_eq!(SortSpec::default().key, SortKey::Name); assert!(SortSpec::default().asc); } #[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"); } }