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