util.rs
⎇
Raw
1//! Formatting + small browser helpers.
2
3use leptos::prelude::*;
4use wasm_bindgen::JsCast;
5use wasm_bindgen::closure::Closure;
6
7use crate::components::toast::{ToastMsg, show, show_error};
8use crate::i18n;
9
10/// Write `text` to the clipboard and confirm it with `msg`.
11///
12/// A rejected write is ignored, so the toast appears even when it failed.
13pub fn copy_to_clipboard(text: &str, msg: &str, toast: ToastMsg) {
14 let Some(win) = web_sys::window() else {
15 show_error(toast, i18n::t(i18n::k::COPY_MANUAL).to_string());
16 return;
17 };
18 let cb = win.navigator().clipboard();
19 let (text, msg) = (text.to_string(), msg.to_string());
20 wasm_bindgen_futures::spawn_local(async move {
21 let _ = wasm_bindgen_futures::JsFuture::from(cb.write_text(&text)).await;
22 show(toast, msg);
23 });
24}
25
26/// A window event listener that is removed when the current owner is disposed.
27///
28/// Leptos' `window_event_listener` returns a `WindowListenerHandle` with no
29/// `Drop` impl — if the owning component is unmounted without an explicit
30/// `remove()`, the listener keeps firing on `window` and panics by reading
31/// the component's now-disposed signals.
32pub fn owned_window_listener<E: leptos::ev::EventDescriptor + 'static>(
33 event: E,
34 cb: impl Fn(E::EventType) + 'static,
35) where
36 E::EventType: wasm_bindgen::JsCast,
37{
38 let handle = window_event_listener(event, cb);
39 on_cleanup(move || handle.remove());
40}
41
42/// A touch long press (500 ms without lifting or moving) that stands in for
43/// a right click. Android fires `contextmenu` on a long press by itself;
44/// iOS Safari does not, so the pointer events are watched directly.
45///
46/// One instance per element. Wire `down`, `up`, `mv` to `pointerdown`,
47/// `pointerup` + `pointercancel` + `pointerleave`, and `pointermove`. The
48/// element's `click` handler must call [`LongPress::swallow_click`] first:
49/// the browser still sends the click of the finger lifting after the menu
50/// opened, and that click would close the menu again.
51#[derive(Clone, Copy)]
52pub struct LongPress {
53 inner: StoredValue<LongPressState, LocalStorage>,
54}
55
56#[derive(Default)]
57struct LongPressState {
58 timer: Option<gloo_timers::callback::Timeout>,
59 start: (i32, i32),
60 fired: bool,
61}
62
63impl LongPress {
64 pub const DELAY_MS: u32 = 500;
65 /// Finger travel that turns the press into a scroll.
66 const SLOP_PX: i32 = 10;
67
68 pub fn new() -> Self {
69 Self {
70 inner: StoredValue::new_local(LongPressState::default()),
71 }
72 }
73
74 /// `pointerdown`: arm the timer for touch pointers; `on_fire` gets the
75 /// client coordinates. Stops propagation so a nested element and its
76 /// container do not both arm.
77 pub fn down(&self, ev: &web_sys::PointerEvent, on_fire: impl Fn(i32, i32) + 'static) {
78 if ev.pointer_type() != "touch" {
79 return;
80 }
81 ev.stop_propagation();
82 let (x, y) = (ev.client_x(), ev.client_y());
83 let inner = self.inner;
84 let timer = gloo_timers::callback::Timeout::new(Self::DELAY_MS, move || {
85 inner.update_value(|st| {
86 st.timer = None;
87 st.fired = true;
88 });
89 on_fire(x, y);
90 });
91 self.inner.update_value(|st| {
92 st.timer = Some(timer);
93 st.start = (x, y);
94 st.fired = false;
95 });
96 }
97
98 /// `pointerup`, `pointercancel`, `pointerleave`: disarm.
99 pub fn up(&self) {
100 self.inner.update_value(|st| st.timer = None);
101 }
102
103 /// `pointermove`: disarm once the finger travels; that is a scroll.
104 pub fn mv(&self, ev: &web_sys::PointerEvent) {
105 self.inner.update_value(|st| {
106 if st.timer.is_none() {
107 return;
108 }
109 let (sx, sy) = st.start;
110 if (ev.client_x() - sx).abs() > Self::SLOP_PX
111 || (ev.client_y() - sy).abs() > Self::SLOP_PX
112 {
113 st.timer = None;
114 }
115 });
116 }
117
118 /// True once, for the click that follows a fired press. The caller
119 /// stops that click instead of handling it.
120 pub fn swallow_click(&self) -> bool {
121 self.inner
122 .try_update_value(|st| std::mem::take(&mut st.fired))
123 .unwrap_or(false)
124 }
125}
126
127/// Same as [`owned_window_listener`], but registered in the capture phase,
128/// so it runs before the bubble-phase window listeners (registration order
129/// otherwise decides, and that depends on component creation order).
130pub fn owned_window_listener_capture<E: leptos::ev::EventDescriptor + 'static>(
131 event: E,
132 cb: impl Fn(E::EventType) + 'static,
133) where
134 E::EventType: wasm_bindgen::JsCast,
135{
136 let closure = Closure::<dyn FnMut(web_sys::Event)>::new({
137 move |e: web_sys::Event| cb(e.unchecked_into::<E::EventType>())
138 });
139 let add_options = web_sys::AddEventListenerOptions::new();
140 add_options.set_capture(true);
141 let remove_options = web_sys::EventListenerOptions::new();
142 remove_options.set_capture(true);
143 let name = event.name();
144 web_sys::window()
145 .expect("window")
146 .add_event_listener_with_callback_and_add_event_listener_options(
147 &name,
148 closure.as_ref().unchecked_ref(),
149 &add_options,
150 )
151 .expect("failed to add window event listener");
152 let closure_js = closure.into_js_value();
153 on_cleanup(move || {
154 let closure: js_sys::Function = closure_js.unchecked_into();
155 let _ = web_sys::window()
156 .expect("window")
157 .remove_event_listener_with_callback_and_event_listener_options(
158 &name,
159 &closure,
160 &remove_options,
161 );
162 });
163}
164
165pub fn format_size(bytes: u64) -> String {
166 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
167 let mut v = bytes as f64;
168 let mut i = 0;
169 while v >= 1024.0 && i < UNITS.len() - 1 {
170 v /= 1024.0;
171 i += 1;
172 }
173 if i == 0 {
174 format!("{bytes} B")
175 } else {
176 format!("{v:.1} {}", UNITS[i])
177 }
178}
179
180/// Format an RFC 3339 timestamp as "YYYY-MM-DD HH:MM" (UTC) for display.
181/// Falls back to the raw string when it does not look like one.
182///
183/// Every timestamp the server sends is UTC with a `Z` suffix, so the display
184/// text is a slice of the input. No date parsing is needed.
185pub fn format_date(rfc3339: &str) -> String {
186 let b = rfc3339.as_bytes();
187 if b.len() >= 16 && b[10] == b'T' {
188 return format!("{} {}", &rfc3339[..10], &rfc3339[11..16]);
189 }
190 rfc3339.to_string()
191}
192
193/// Format a JS date in the UI language with `Intl` options such as
194/// `[("month", "long")]`. Local time, unless the options name a `timeZone`.
195pub fn intl(date: &js_sys::Date, options: &[(&str, &str)]) -> String {
196 let opts = js_sys::Object::new();
197 for (k, v) in options {
198 let _ = js_sys::Reflect::set(&opts, &(*k).into(), &(*v).into());
199 }
200 date.to_locale_string(i18n::current_untracked().code(), &opts)
201 .into()
202}
203
204/// An RFC 3339 timestamp as local date and time, e.g. "5 Oct 2026, 10:00".
205pub fn local_date_time(rfc3339: &str) -> String {
206 let d = js_sys::Date::new(&rfc3339.into());
207 if d.get_time().is_nan() {
208 return rfc3339.to_string();
209 }
210 intl(&d, &[("dateStyle", "medium"), ("timeStyle", "short")])
211}
212
213pub fn storage_get(key: &str) -> Option<String> {
214 let w = web_sys::window()?;
215 let storage_opt = w.local_storage().ok()?;
216 let storage = storage_opt.as_ref()?;
217 storage.get_item(key).ok().flatten()
218}
219
220pub fn storage_set(key: &str, value: &str) {
221 if let Some(s) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) {
222 let _ = s.set_item(key, value);
223 }
224}
225
226#[derive(Clone, Copy, PartialEq, Debug)]
227pub enum ViewMode {
228 Grid,
229 List,
230}
231
232impl ViewMode {
233 const KEY: &'static str = "dovenest.view";
234
235 pub fn load() -> Self {
236 match storage_get(Self::KEY).as_deref() {
237 Some("list") => ViewMode::List,
238 _ => ViewMode::Grid,
239 }
240 }
241
242 pub fn save(self) {
243 storage_set(
244 Self::KEY,
245 if self == ViewMode::List {
246 "list"
247 } else {
248 "grid"
249 },
250 );
251 }
252}
253
254pub use api_types::SortKey;
255
256/// A sort key plus its direction, persisted like [`ViewMode`].
257#[derive(Clone, Copy, PartialEq, Eq, Debug)]
258pub struct SortSpec {
259 pub key: SortKey,
260 pub asc: bool,
261}
262
263impl Default for SortSpec {
264 fn default() -> Self {
265 Self {
266 key: SortKey::Name,
267 asc: true,
268 }
269 }
270}
271
272impl SortSpec {
273 const KEY: &'static str = "dovenest.sort";
274
275 pub fn load() -> Self {
276 let raw = storage_get(Self::KEY).unwrap_or_default();
277 let (key, dir) = raw.split_once(':').unwrap_or(("", ""));
278 match SortKey::parse(key) {
279 Some(key) => Self {
280 key,
281 asc: dir != "desc",
282 },
283 None => Self::default(),
284 }
285 }
286
287 pub fn save(self) {
288 let dir = if self.asc { "asc" } else { "desc" };
289 storage_set(Self::KEY, &format!("{}:{dir}", self.key.as_str()));
290 }
291}
292
293/// Rows rendered at once: the search's "show more" step, and the folder
294/// listing's default page size. Rendering a row costs a fixed amount of
295/// reactive-graph setup, so the first paint stays well under a frame and the
296/// user pays for more rows only by asking for them.
297pub const FILE_PAGE: usize = 100;
298
299/// Footer under a paged list: where the user is in the list, and a button
300/// to reveal the next page.
301///
302/// `shown` of `total` is stated plainly rather than only counting what is
303/// hidden — "Showing 100 of 6027" answers "where am I?", which a bare
304/// "5927 more…" does not. The button appears only while more items are
305/// actually retained; past the caller's retain cap the count is all that is
306/// left to say, so the footer becomes a plain line.
307///
308/// Returns nothing at all when everything is on screen.
309pub fn page_footer(
310 shown: usize,
311 retained: usize,
312 total: usize,
313 page: usize,
314 reveal: impl Fn() + 'static,
315) -> AnyView {
316 if total <= shown {
317 return view! {}.into_any();
318 }
319 let label = crate::i18n::t_fmt2(
320 crate::i18n::k::SEARCH_SHOWN_OF,
321 &shown.to_string(),
322 &total.to_string(),
323 );
324 let pageable = retained.saturating_sub(shown);
325 if pageable == 0 {
326 return view! { <div class="page-foot">{label}</div> }.into_any();
327 }
328 let next = pageable.min(page);
329 view! {
330 <div class="page-foot">
331 <span class="pf-count">{label}</span>
332 <button class="pf-more" on:click=move |_| reveal()>
333 {crate::i18n::t_fmt(crate::i18n::k::SEARCH_SHOW_MORE, &next.to_string())}
334 </button>
335 </div>
336 }
337 .into_any()
338}
339
340/// Page sizes the folder listing offers.
341pub const PAGE_SIZES: [usize; 4] = [50, 100, 200, 500];
342
343const PAGE_SIZE_KEY: &str = "dovenest.page_size";
344
345pub fn load_page_size() -> usize {
346 storage_get(PAGE_SIZE_KEY)
347 .and_then(|v| v.parse().ok())
348 .filter(|n| PAGE_SIZES.contains(n))
349 .unwrap_or(FILE_PAGE)
350}
351
352pub fn save_page_size(n: usize) {
353 storage_set(PAGE_SIZE_KEY, &n.to_string());
354}
355
356/// The page buttons to show, 0-based: the first, the last, and two on each
357/// side of `cur`. `None` is a gap.
358pub fn page_numbers(cur: usize, pages: usize) -> Vec<Option<usize>> {
359 let mut out = Vec::new();
360 for p in 0..pages {
361 if p == 0 || p + 1 == pages || p.abs_diff(cur) <= 2 {
362 out.push(Some(p));
363 } else if out.last() != Some(&None) {
364 out.push(None);
365 }
366 }
367 out
368}
369
370/// Footer under a server-paged list. Empty when the list fits the smallest
371/// page size, since then no control does anything.
372pub fn pager(
373 offset: usize,
374 shown: usize,
375 total: usize,
376 size: usize,
377 go: Callback<usize>,
378 set_size: Callback<usize>,
379) -> AnyView {
380 use crate::i18n::k;
381 if total <= PAGE_SIZES[0] {
382 return view! {}.into_any();
383 }
384 let cur = offset / size;
385 let pages = total.div_ceil(size);
386 let last = pages - 1;
387 let label = crate::i18n::t_fmt2(
388 k::SEARCH_SHOWN_OF,
389 &format!("{}–{}", offset + 1, offset + shown),
390 &total.to_string(),
391 );
392 let nav = move |glyph: &'static str, title: &'static str, to: usize, off: bool| {
393 view! {
394 <button
395 class="pf-btn"
396 disabled=off
397 title=crate::i18n::tr(title)
398 aria-label=crate::i18n::tr(title)
399 on:click=move |_| go.run(to)
400 >
401 {glyph}
402 </button>
403 }
404 };
405 let numbers = page_numbers(cur, pages)
406 .into_iter()
407 .map(|p| match p {
408 Some(p) => view! {
409 <button
410 class="pf-btn"
411 class:current=p == cur
412 aria-current=(p == cur).then_some("page")
413 on:click=move |_| go.run(p)
414 >
415 {p + 1}
416 </button>
417 }
418 .into_any(),
419 None => view! { <span class="pf-gap">"…"</span> }.into_any(),
420 })
421 .collect_view();
422 view! {
423 <div class="page-foot">
424 <span class="pf-count">{label}</span>
425 <nav class="pf-pages">
426 {nav("«", k::PAGE_FIRST, 0, cur == 0)}
427 {nav("‹", k::PAGE_PREV, cur.saturating_sub(1), cur == 0)}
428 {numbers}
429 {nav("›", k::PAGE_NEXT, (cur + 1).min(last), cur == last)}
430 {nav("»", k::PAGE_LAST, last, cur == last)}
431 </nav>
432 <label class="pf-goto">
433 {crate::i18n::tr(k::PAGE_GOTO)}
434 <input
435 type="number"
436 min="1"
437 max=pages.to_string()
438 prop:value=(cur + 1).to_string()
439 on:change=move |ev| {
440 if let Ok(n) = event_target_value(&ev).trim().parse::<usize>() {
441 go.run(n.clamp(1, pages) - 1);
442 }
443 }
444 />
445 </label>
446 <select
447 class="sort-select"
448 aria-label=crate::i18n::tr(k::PAGE_SIZE)
449 on:change=move |ev| {
450 if let Ok(n) = event_target_value(&ev).parse() {
451 set_size.run(n);
452 }
453 }
454 >
455 {PAGE_SIZES
456 .map(|n| view! {
457 <option value=n.to_string() selected=n == size>
458 {crate::i18n::t_fmt(k::PAGE_SIZE_N, &n.to_string())}
459 </option>
460 })
461 .to_vec()}
462 </select>
463 </div>
464 }
465 .into_any()
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 #[test]
473 fn page_numbers_keep_the_ends_and_the_neighbours() {
474 let n = |cur, pages| page_numbers(cur, pages);
475 assert_eq!(n(0, 1), [Some(0)]);
476 assert_eq!(n(2, 5), [Some(0), Some(1), Some(2), Some(3), Some(4)]);
477 assert_eq!(n(0, 10), [Some(0), Some(1), Some(2), None, Some(9)]);
478 assert_eq!(
479 n(5, 10),
480 [
481 Some(0),
482 None,
483 Some(3),
484 Some(4),
485 Some(5),
486 Some(6),
487 Some(7),
488 None,
489 Some(9)
490 ]
491 );
492 }
493
494 #[test]
495 fn sort_spec_round_trips_through_its_stored_form() {
496 // No localStorage in the test runner, so only the parse half runs
497 // here. `load` falls back to the default.
498 assert_eq!(SortKey::parse("size"), Some(SortKey::Size));
499 assert_eq!(SortKey::parse("bogus"), None);
500 assert_eq!(SortSpec::default().key, SortKey::Name);
501 assert!(SortSpec::default().asc);
502 }
503
504 #[test]
505 fn format_date_drops_seconds_and_zone() {
506 assert_eq!(format_date("2026-09-11T12:34:56Z"), "2026-09-11 12:34");
507 assert_eq!(format_date("nonsense"), "nonsense");
508 }
509}
510