//! Formatting + small browser helpers.
use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen::closure::Closure;
use crate::components::icon::icon_svg;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n;
use crate::icons::IconName;
/// 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 button that copies `text` and toasts the `msg` key. `title` is a key too.
pub fn copy_button(
class: &'static str,
title: Option<&'static str>,
label: impl IntoView + 'static,
text: String,
msg: &'static str,
toast: ToastMsg,
) -> impl IntoView {
view! {
}
}
/// A read-only URL with a copy button.
pub fn url_row(label: &'static str, url: String, toast: ToastMsg) -> impl IntoView {
view! {
}
}
/// A failed load, with a button to try again.
pub fn retry_card(msg: String, retry: Callback<()>) -> impl IntoView {
view! {
{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