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
165/// Select the whole value of the `<input>` behind an event, so one click
166/// leaves the link ready to copy by hand.
167pub fn select_input(ev: &web_sys::Event) {
168 if let Some(i) = ev
169 .target()
170 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
171 {
172 i.select();
173 }
174}
175
176/// The value of a `<select>` behind an event, when the target is one.
177pub fn select_value(ev: &web_sys::Event) -> Option<String> {
178 ev.target()
179 .and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
180 .map(|s| s.value())
181}
182
183pub fn format_size(bytes: u64) -> String {
184 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
185 let mut v = bytes as f64;
186 let mut i = 0;
187 while v >= 1024.0 && i < UNITS.len() - 1 {
188 v /= 1024.0;
189 i += 1;
190 }
191 if i == 0 {
192 format!("{bytes} B")
193 } else {
194 format!("{v:.1} {}", UNITS[i])
195 }
196}
197
198/// Format an RFC 3339 timestamp as "YYYY-MM-DD HH:MM" (UTC) for display.
199/// Falls back to the raw string when it does not look like one.
200///
201/// Every timestamp the server sends is UTC with a `Z` suffix, so the display
202/// text is a slice of the input. No date parsing is needed.
203pub fn format_date(rfc3339: &str) -> String {
204 let b = rfc3339.as_bytes();
205 if b.len() >= 16 && b[10] == b'T' {
206 return format!("{} {}", &rfc3339[..10], &rfc3339[11..16]);
207 }
208 rfc3339.to_string()
209}
210
211pub fn storage_get(key: &str) -> Option<String> {
212 let w = web_sys::window()?;
213 let storage_opt = w.local_storage().ok()?;
214 let storage = storage_opt.as_ref()?;
215 storage.get_item(key).ok().flatten()
216}
217
218pub fn storage_set(key: &str, value: &str) {
219 if let Some(s) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) {
220 let _ = s.set_item(key, value);
221 }
222}
223
224#[derive(Clone, Copy, PartialEq, Debug)]
225pub enum ViewMode {
226 Grid,
227 List,
228}
229
230impl ViewMode {
231 const KEY: &'static str = "fbng.view";
232
233 pub fn load() -> Self {
234 match storage_get(Self::KEY).as_deref() {
235 Some("list") => ViewMode::List,
236 _ => ViewMode::Grid,
237 }
238 }
239
240 pub fn save(self) {
241 storage_set(
242 Self::KEY,
243 if self == ViewMode::List {
244 "list"
245 } else {
246 "grid"
247 },
248 );
249 }
250}
251
252/// What a listing is ordered by. Folders always sort before files, so a
253/// size or date order does not scatter them through the listing.
254#[derive(Clone, Copy, PartialEq, Eq, Debug)]
255pub enum SortKey {
256 Name,
257 Size,
258 Modified,
259}
260
261impl SortKey {
262 pub fn as_str(self) -> &'static str {
263 match self {
264 SortKey::Name => "name",
265 SortKey::Size => "size",
266 SortKey::Modified => "modified",
267 }
268 }
269
270 pub fn parse(s: &str) -> Option<Self> {
271 match s {
272 "name" => Some(SortKey::Name),
273 "size" => Some(SortKey::Size),
274 "modified" => Some(SortKey::Modified),
275 _ => None,
276 }
277 }
278}
279
280/// A sort key plus its direction, persisted like [`ViewMode`].
281#[derive(Clone, Copy, PartialEq, Eq, Debug)]
282pub struct SortSpec {
283 pub key: SortKey,
284 pub asc: bool,
285}
286
287impl Default for SortSpec {
288 fn default() -> Self {
289 // The server's own order, so the first paint after a fetch does not
290 // reshuffle.
291 Self {
292 key: SortKey::Name,
293 asc: true,
294 }
295 }
296}
297
298impl SortSpec {
299 const KEY: &'static str = "fbng.sort";
300
301 pub fn load() -> Self {
302 let raw = storage_get(Self::KEY).unwrap_or_default();
303 let (key, dir) = raw.split_once(':').unwrap_or(("", ""));
304 match SortKey::parse(key) {
305 Some(key) => Self {
306 key,
307 asc: dir != "desc",
308 },
309 None => Self::default(),
310 }
311 }
312
313 pub fn save(self) {
314 let dir = if self.asc { "asc" } else { "desc" };
315 storage_set(Self::KEY, &format!("{}:{dir}", self.key.as_str()));
316 }
317
318 /// Order `entries` in place.
319 ///
320 /// Name is compared case-insensitively, with the raw name as the
321 /// tie-breaker so two names differing only in case keep a stable order.
322 /// Folders have no meaningful size, so they fall back to the name order
323 /// among themselves.
324 pub fn apply(self, entries: &mut [crate::api::Entry]) {
325 match self.key {
326 SortKey::Name => entries.sort_by(|a, b| {
327 a.is_dir
328 .cmp(&b.is_dir)
329 .reverse()
330 .then_with(|| cmp_name(a, b).dir(self.asc))
331 }),
332 SortKey::Size => entries.sort_by(|a, b| {
333 a.is_dir.cmp(&b.is_dir).reverse().then_with(|| {
334 if a.is_dir {
335 cmp_name(a, b)
336 } else {
337 a.size
338 .cmp(&b.size)
339 .dir(self.asc)
340 .then_with(|| cmp_name(a, b))
341 }
342 })
343 }),
344 SortKey::Modified => entries.sort_by(|a, b| {
345 a.is_dir.cmp(&b.is_dir).reverse().then_with(|| {
346 // RFC 3339 UTC with a fixed width, so bytewise order is
347 // chronological order.
348 a.mtime
349 .cmp(&b.mtime)
350 .dir(self.asc)
351 .then_with(|| cmp_name(a, b))
352 })
353 }),
354 }
355 }
356}
357
358fn cmp_name(a: &crate::api::Entry, b: &crate::api::Entry) -> std::cmp::Ordering {
359 a.name
360 .to_lowercase()
361 .cmp(&b.name.to_lowercase())
362 .then_with(|| a.name.cmp(&b.name))
363}
364
365trait OrderingDir {
366 fn dir(self, asc: bool) -> std::cmp::Ordering;
367}
368
369impl OrderingDir for std::cmp::Ordering {
370 fn dir(self, asc: bool) -> std::cmp::Ordering {
371 if asc { self } else { self.reverse() }
372 }
373}
374
375/// Entries rendered at once in a listing, and how many more each click of
376/// the "show more" row reveals. Rendering a row costs a fixed amount of
377/// reactive-graph setup, so the first paint stays well under a frame and the
378/// user pays for more rows only by asking for them.
379pub const FILE_PAGE: usize = 100;
380
381/// Footer under a paged list: where the user is in the list, and a button
382/// to reveal the next page.
383///
384/// `shown` of `total` is stated plainly rather than only counting what is
385/// hidden — "Showing 100 of 6027" answers "where am I?", which a bare
386/// "5927 more…" does not. The button appears only while more items are
387/// actually retained; past the caller's retain cap the count is all that is
388/// left to say, so the footer becomes a plain line.
389///
390/// Returns nothing at all when everything is on screen.
391pub fn page_footer(
392 shown: usize,
393 retained: usize,
394 total: usize,
395 page: usize,
396 reveal: impl Fn() + 'static,
397) -> AnyView {
398 if total <= shown {
399 return view! {}.into_view().into_any();
400 }
401 let label = crate::i18n::t_fmt2(
402 crate::i18n::k::SEARCH_SHOWN_OF,
403 &shown.to_string(),
404 &total.to_string(),
405 );
406 let pageable = retained.saturating_sub(shown);
407 if pageable == 0 {
408 return view! { <div class="page-foot">{label}</div> }
409 .into_view()
410 .into_any();
411 }
412 let next = pageable.min(page);
413 view! {
414 <div class="page-foot">
415 <span class="pf-count">{label}</span>
416 <button class="pf-more" on:click=move |_| reveal()>
417 {crate::i18n::t_fmt(crate::i18n::k::SEARCH_SHOW_MORE, &next.to_string())}
418 </button>
419 </div>
420 }
421 .into_view()
422 .into_any()
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 fn entry(name: &str, is_dir: bool, size: u64, mtime: &str) -> crate::api::Entry {
430 crate::api::Entry {
431 name: name.to_string(),
432 is_dir,
433 size,
434 mtime: mtime.to_string(),
435 kind: if is_dir {
436 api_types::FileKind::Dir
437 } else {
438 api_types::FileKind::Binary
439 },
440 }
441 }
442
443 /// This order is what the browser's arrow keys and Shift+click ranges
444 /// walk, so a wrong order moves the wrong file.
445 #[test]
446 fn sort_keeps_folders_first_in_every_order() {
447 let mut v = vec![
448 entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
449 entry("Zdir", true, 0, "2026-01-01T00:00:00Z"),
450 entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
451 ];
452 for key in [SortKey::Name, SortKey::Size, SortKey::Modified] {
453 for asc in [true, false] {
454 SortSpec { key, asc }.apply(&mut v);
455 assert!(v[0].is_dir, "{key:?} asc={asc} put a file first");
456 }
457 }
458 }
459
460 #[test]
461 fn sort_orders_by_the_chosen_key() {
462 let mut v = vec![
463 entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
464 entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
465 entry("c.txt", false, 20, "2026-01-01T00:00:00Z"),
466 ];
467 let names = |v: &[crate::api::Entry]| v.iter().map(|e| e.name.clone()).collect::<Vec<_>>();
468
469 SortSpec {
470 key: SortKey::Name,
471 asc: true,
472 }
473 .apply(&mut v);
474 assert_eq!(names(&v), ["a.txt", "b.txt", "c.txt"]);
475 SortSpec {
476 key: SortKey::Name,
477 asc: false,
478 }
479 .apply(&mut v);
480 assert_eq!(names(&v), ["c.txt", "b.txt", "a.txt"]);
481
482 SortSpec {
483 key: SortKey::Size,
484 asc: true,
485 }
486 .apply(&mut v);
487 assert_eq!(names(&v), ["b.txt", "c.txt", "a.txt"]);
488 SortSpec {
489 key: SortKey::Modified,
490 asc: true,
491 }
492 .apply(&mut v);
493 assert_eq!(names(&v), ["c.txt", "b.txt", "a.txt"]);
494 }
495
496 #[test]
497 fn sort_spec_round_trips_through_its_stored_form() {
498 // No localStorage in the test runner, so only the parse half runs
499 // here. `load` falls back to the default.
500 assert_eq!(SortKey::parse("size"), Some(SortKey::Size));
501 assert_eq!(SortKey::parse("bogus"), None);
502 assert_eq!(SortSpec::default().key, SortKey::Name);
503 assert!(SortSpec::default().asc);
504 }
505
506 #[test]
507 fn format_date_drops_seconds_and_zone() {
508 assert_eq!(format_date("2026-09-11T12:34:56Z"), "2026-09-11 12:34");
509 assert_eq!(format_date("nonsense"), "nonsense");
510 }
511}
512