util.rs
⎇
Raw
1//! Formatting + small browser helpers.
2
3use leptos::prelude::*;
4use wasm_bindgen::JsCast;
5use wasm_bindgen::closure::Closure;
6
7/// A window event listener that is removed when the current owner is disposed.
8///
9/// Leptos' `window_event_listener` returns a `WindowListenerHandle` with no
10/// `Drop` impl — if the owning component is unmounted without an explicit
11/// `remove()`, the listener keeps firing on `window` and panics by reading
12/// the component's now-disposed signals.
13pub fn owned_window_listener<E: leptos::ev::EventDescriptor + 'static>(
14 event: E,
15 cb: impl Fn(E::EventType) + 'static,
16) where
17 E::EventType: wasm_bindgen::JsCast,
18{
19 let handle = window_event_listener(event, cb);
20 on_cleanup(move || handle.remove());
21}
22
23/// A touch long press (500 ms without lifting or moving) that stands in for
24/// a right click. Android fires `contextmenu` on a long press by itself;
25/// iOS Safari does not, so the pointer events are watched directly.
26///
27/// One instance per element. Wire `down`, `up`, `mv` to `pointerdown`,
28/// `pointerup` + `pointercancel` + `pointerleave`, and `pointermove`. The
29/// element's `click` handler must call [`LongPress::swallow_click`] first:
30/// the browser still sends the click of the finger lifting after the menu
31/// opened, and that click would close the menu again.
32#[derive(Clone, Copy)]
33pub struct LongPress {
34 inner: StoredValue<LongPressState, LocalStorage>,
35}
36
37#[derive(Default)]
38struct LongPressState {
39 timer: Option<gloo_timers::callback::Timeout>,
40 start: (i32, i32),
41 fired: bool,
42}
43
44impl LongPress {
45 pub const DELAY_MS: u32 = 500;
46 /// Finger travel that turns the press into a scroll.
47 const SLOP_PX: i32 = 10;
48
49 pub fn new() -> Self {
50 Self {
51 inner: StoredValue::new_local(LongPressState::default()),
52 }
53 }
54
55 /// `pointerdown`: arm the timer for touch pointers; `on_fire` gets the
56 /// client coordinates. Stops propagation so a nested element and its
57 /// container do not both arm.
58 pub fn down(&self, ev: &web_sys::PointerEvent, on_fire: impl Fn(i32, i32) + 'static) {
59 if ev.pointer_type() != "touch" {
60 return;
61 }
62 ev.stop_propagation();
63 let (x, y) = (ev.client_x(), ev.client_y());
64 let inner = self.inner;
65 let timer = gloo_timers::callback::Timeout::new(Self::DELAY_MS, move || {
66 inner.update_value(|st| {
67 st.timer = None;
68 st.fired = true;
69 });
70 on_fire(x, y);
71 });
72 self.inner.update_value(|st| {
73 st.timer = Some(timer);
74 st.start = (x, y);
75 st.fired = false;
76 });
77 }
78
79 /// `pointerup`, `pointercancel`, `pointerleave`: disarm.
80 pub fn up(&self) {
81 self.inner.update_value(|st| st.timer = None);
82 }
83
84 /// `pointermove`: disarm once the finger travels; that is a scroll.
85 pub fn mv(&self, ev: &web_sys::PointerEvent) {
86 self.inner.update_value(|st| {
87 if st.timer.is_none() {
88 return;
89 }
90 let (sx, sy) = st.start;
91 if (ev.client_x() - sx).abs() > Self::SLOP_PX
92 || (ev.client_y() - sy).abs() > Self::SLOP_PX
93 {
94 st.timer = None;
95 }
96 });
97 }
98
99 /// True once, for the click that follows a fired press. The caller
100 /// stops that click instead of handling it.
101 pub fn swallow_click(&self) -> bool {
102 self.inner
103 .try_update_value(|st| std::mem::take(&mut st.fired))
104 .unwrap_or(false)
105 }
106}
107
108/// Same as [`owned_window_listener`], but registered in the capture phase,
109/// so it runs before the bubble-phase window listeners (registration order
110/// otherwise decides, and that depends on component creation order).
111pub fn owned_window_listener_capture<E: leptos::ev::EventDescriptor + 'static>(
112 event: E,
113 cb: impl Fn(E::EventType) + 'static,
114) where
115 E::EventType: wasm_bindgen::JsCast,
116{
117 let closure = Closure::<dyn FnMut(web_sys::Event)>::new({
118 move |e: web_sys::Event| cb(e.unchecked_into::<E::EventType>())
119 });
120 let add_options = web_sys::AddEventListenerOptions::new();
121 add_options.set_capture(true);
122 let remove_options = web_sys::EventListenerOptions::new();
123 remove_options.set_capture(true);
124 let name = event.name();
125 web_sys::window()
126 .expect("window")
127 .add_event_listener_with_callback_and_add_event_listener_options(
128 &name,
129 closure.as_ref().unchecked_ref(),
130 &add_options,
131 )
132 .expect("failed to add window event listener");
133 let closure_js = closure.into_js_value();
134 on_cleanup(move || {
135 let closure: js_sys::Function = closure_js.unchecked_into();
136 let _ = web_sys::window()
137 .expect("window")
138 .remove_event_listener_with_callback_and_event_listener_options(
139 &name,
140 &closure,
141 &remove_options,
142 );
143 });
144}
145
146/// Select the whole value of the `<input>` behind an event, so one click
147/// leaves the link ready to copy by hand.
148pub fn select_input(ev: &web_sys::Event) {
149 if let Some(i) = ev
150 .target()
151 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
152 {
153 i.select();
154 }
155}
156
157/// The value of a `<select>` behind an event, when the target is one.
158pub fn select_value(ev: &web_sys::Event) -> Option<String> {
159 ev.target()
160 .and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
161 .map(|s| s.value())
162}
163
164pub fn format_size(bytes: u64) -> String {
165 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
166 let mut v = bytes as f64;
167 let mut i = 0;
168 while v >= 1024.0 && i < UNITS.len() - 1 {
169 v /= 1024.0;
170 i += 1;
171 }
172 if i == 0 {
173 format!("{bytes} B")
174 } else {
175 format!("{v:.1} {}", UNITS[i])
176 }
177}
178
179/// Format an RFC 3339 timestamp as "YYYY-MM-DD HH:MM" (UTC) for display.
180/// Falls back to the raw string when it does not look like one.
181///
182/// Every timestamp the server sends is UTC with a `Z` suffix, so the display
183/// text is a slice of the input. No date parsing is needed.
184pub fn format_date(rfc3339: &str) -> String {
185 let b = rfc3339.as_bytes();
186 if b.len() >= 16 && b[10] == b'T' {
187 return format!("{} {}", &rfc3339[..10], &rfc3339[11..16]);
188 }
189 rfc3339.to_string()
190}
191
192pub fn storage_get(key: &str) -> Option<String> {
193 let w = web_sys::window()?;
194 let storage_opt = w.local_storage().ok()?;
195 let storage = storage_opt.as_ref()?;
196 storage.get_item(key).ok().flatten()
197}
198
199pub fn storage_set(key: &str, value: &str) {
200 if let Some(s) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) {
201 let _ = s.set_item(key, value);
202 }
203}
204
205#[derive(Clone, Copy, PartialEq, Debug)]
206pub enum ViewMode {
207 Grid,
208 List,
209}
210
211impl ViewMode {
212 const KEY: &'static str = "fbng.view";
213
214 pub fn load() -> Self {
215 match storage_get(Self::KEY).as_deref() {
216 Some("list") => ViewMode::List,
217 _ => ViewMode::Grid,
218 }
219 }
220
221 pub fn save(self) {
222 storage_set(
223 Self::KEY,
224 if self == ViewMode::List {
225 "list"
226 } else {
227 "grid"
228 },
229 );
230 }
231}
232
233/// What a listing is ordered by. Folders always sort before files, so a
234/// size or date order does not scatter them through the listing.
235#[derive(Clone, Copy, PartialEq, Eq, Debug)]
236pub enum SortKey {
237 Name,
238 Size,
239 Modified,
240}
241
242impl SortKey {
243 pub fn as_str(self) -> &'static str {
244 match self {
245 SortKey::Name => "name",
246 SortKey::Size => "size",
247 SortKey::Modified => "modified",
248 }
249 }
250
251 pub fn parse(s: &str) -> Option<Self> {
252 match s {
253 "name" => Some(SortKey::Name),
254 "size" => Some(SortKey::Size),
255 "modified" => Some(SortKey::Modified),
256 _ => None,
257 }
258 }
259}
260
261/// A sort key plus its direction, persisted like [`ViewMode`].
262#[derive(Clone, Copy, PartialEq, Eq, Debug)]
263pub struct SortSpec {
264 pub key: SortKey,
265 pub asc: bool,
266}
267
268impl Default for SortSpec {
269 fn default() -> Self {
270 // The server's own order, so the first paint after a fetch does not
271 // reshuffle.
272 Self {
273 key: SortKey::Name,
274 asc: true,
275 }
276 }
277}
278
279impl SortSpec {
280 const KEY: &'static str = "fbng.sort";
281
282 pub fn load() -> Self {
283 let raw = storage_get(Self::KEY).unwrap_or_default();
284 let (key, dir) = raw.split_once(':').unwrap_or(("", ""));
285 match SortKey::parse(key) {
286 Some(key) => Self {
287 key,
288 asc: dir != "desc",
289 },
290 None => Self::default(),
291 }
292 }
293
294 pub fn save(self) {
295 let dir = if self.asc { "asc" } else { "desc" };
296 storage_set(Self::KEY, &format!("{}:{dir}", self.key.as_str()));
297 }
298
299 /// Order `entries` in place.
300 ///
301 /// Name is compared case-insensitively, with the raw name as the
302 /// tie-breaker so two names differing only in case keep a stable order.
303 /// Folders have no meaningful size, so they fall back to the name order
304 /// among themselves.
305 pub fn apply(self, entries: &mut [crate::api::Entry]) {
306 match self.key {
307 SortKey::Name => entries.sort_by(|a, b| {
308 a.is_dir
309 .cmp(&b.is_dir)
310 .reverse()
311 .then_with(|| cmp_name(a, b).dir(self.asc))
312 }),
313 SortKey::Size => entries.sort_by(|a, b| {
314 a.is_dir.cmp(&b.is_dir).reverse().then_with(|| {
315 if a.is_dir {
316 cmp_name(a, b)
317 } else {
318 a.size
319 .cmp(&b.size)
320 .dir(self.asc)
321 .then_with(|| cmp_name(a, b))
322 }
323 })
324 }),
325 SortKey::Modified => entries.sort_by(|a, b| {
326 a.is_dir.cmp(&b.is_dir).reverse().then_with(|| {
327 // RFC 3339 UTC with a fixed width, so bytewise order is
328 // chronological order.
329 a.mtime
330 .cmp(&b.mtime)
331 .dir(self.asc)
332 .then_with(|| cmp_name(a, b))
333 })
334 }),
335 }
336 }
337}
338
339fn cmp_name(a: &crate::api::Entry, b: &crate::api::Entry) -> std::cmp::Ordering {
340 a.name
341 .to_lowercase()
342 .cmp(&b.name.to_lowercase())
343 .then_with(|| a.name.cmp(&b.name))
344}
345
346trait OrderingDir {
347 fn dir(self, asc: bool) -> std::cmp::Ordering;
348}
349
350impl OrderingDir for std::cmp::Ordering {
351 fn dir(self, asc: bool) -> std::cmp::Ordering {
352 if asc { self } else { self.reverse() }
353 }
354}
355
356/// Entries rendered at once in a listing, and how many more each click of
357/// the "show more" row reveals. Rendering a row costs a fixed amount of
358/// reactive-graph setup, so the first paint stays well under a frame and the
359/// user pays for more rows only by asking for them.
360pub const FILE_PAGE: usize = 100;
361
362/// Footer under a paged list: where the user is in the list, and a button
363/// to reveal the next page.
364///
365/// `shown` of `total` is stated plainly rather than only counting what is
366/// hidden — "Showing 100 of 6027" answers "where am I?", which a bare
367/// "5927 more…" does not. The button appears only while more items are
368/// actually retained; past the caller's retain cap the count is all that is
369/// left to say, so the footer becomes a plain line.
370///
371/// Returns nothing at all when everything is on screen.
372pub fn page_footer(
373 shown: usize,
374 retained: usize,
375 total: usize,
376 page: usize,
377 reveal: impl Fn() + 'static,
378) -> AnyView {
379 if total <= shown {
380 return view! {}.into_view().into_any();
381 }
382 let label = crate::i18n::t_fmt2(
383 crate::i18n::k::SEARCH_SHOWN_OF,
384 &shown.to_string(),
385 &total.to_string(),
386 );
387 let pageable = retained.saturating_sub(shown);
388 if pageable == 0 {
389 return view! { <div class="page-foot">{label}</div> }
390 .into_view()
391 .into_any();
392 }
393 let next = pageable.min(page);
394 view! {
395 <div class="page-foot">
396 <span class="pf-count">{label}</span>
397 <button class="pf-more" on:click=move |_| reveal()>
398 {crate::i18n::t_fmt(crate::i18n::k::SEARCH_SHOW_MORE, &next.to_string())}
399 </button>
400 </div>
401 }
402 .into_view()
403 .into_any()
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 fn entry(name: &str, is_dir: bool, size: u64, mtime: &str) -> crate::api::Entry {
411 crate::api::Entry {
412 name: name.to_string(),
413 is_dir,
414 size,
415 mtime: mtime.to_string(),
416 kind: if is_dir {
417 api_types::FileKind::Dir
418 } else {
419 api_types::FileKind::Binary
420 },
421 }
422 }
423
424 /// This order is what the browser's arrow keys and Shift+click ranges
425 /// walk, so a wrong order moves the wrong file.
426 #[test]
427 fn sort_keeps_folders_first_in_every_order() {
428 let mut v = vec![
429 entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
430 entry("Zdir", true, 0, "2026-01-01T00:00:00Z"),
431 entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
432 ];
433 for key in [SortKey::Name, SortKey::Size, SortKey::Modified] {
434 for asc in [true, false] {
435 SortSpec { key, asc }.apply(&mut v);
436 assert!(v[0].is_dir, "{key:?} asc={asc} put a file first");
437 }
438 }
439 }
440
441 #[test]
442 fn sort_orders_by_the_chosen_key() {
443 let mut v = vec![
444 entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
445 entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
446 entry("c.txt", false, 20, "2026-01-01T00:00:00Z"),
447 ];
448 let names = |v: &[crate::api::Entry]| v.iter().map(|e| e.name.clone()).collect::<Vec<_>>();
449
450 SortSpec {
451 key: SortKey::Name,
452 asc: true,
453 }
454 .apply(&mut v);
455 assert_eq!(names(&v), ["a.txt", "b.txt", "c.txt"]);
456 SortSpec {
457 key: SortKey::Name,
458 asc: false,
459 }
460 .apply(&mut v);
461 assert_eq!(names(&v), ["c.txt", "b.txt", "a.txt"]);
462
463 SortSpec {
464 key: SortKey::Size,
465 asc: true,
466 }
467 .apply(&mut v);
468 assert_eq!(names(&v), ["b.txt", "c.txt", "a.txt"]);
469 SortSpec {
470 key: SortKey::Modified,
471 asc: true,
472 }
473 .apply(&mut v);
474 assert_eq!(names(&v), ["c.txt", "b.txt", "a.txt"]);
475 }
476
477 #[test]
478 fn sort_spec_round_trips_through_its_stored_form() {
479 // No localStorage in the test runner, so only the parse half runs
480 // here. `load` falls back to the default.
481 assert_eq!(SortKey::parse("size"), Some(SortKey::Size));
482 assert_eq!(SortKey::parse("bogus"), None);
483 assert_eq!(SortSpec::default().key, SortKey::Name);
484 assert!(SortSpec::default().asc);
485 }
486
487 #[test]
488 fn format_date_drops_seconds_and_zone() {
489 assert_eq!(format_date("2026-09-11T12:34:56Z"), "2026-09-11 12:34");
490 assert_eq!(format_date("nonsense"), "nonsense");
491 }
492}
493