use leptos::*; use leptos_use::{on_click_outside, use_timeout_fn}; use crate::store::{get_action_messages, show_toast_with_signal}; use crate::api; use shared::NotificationLevel; fn format_bytes(bytes: i64) -> String { const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"]; if bytes < 1024 { return format!("{} B", bytes); } let i = (bytes as f64).log2().div_euclid(10.0) as usize; format!( "{:.1} {}", (bytes as f64) / 1024_f64.powi(i as i32), UNITS[i] ) } fn format_speed(bytes_per_sec: i64) -> String { if bytes_per_sec == 0 { return "0 B/s".to_string(); } format!("{}/s", format_bytes(bytes_per_sec)) } fn format_duration(seconds: i64) -> String { if seconds <= 0 { return "∞".to_string(); } let days = seconds / 86400; let hours = (seconds % 86400) / 3600; let minutes = (seconds % 3600) / 60; let secs = seconds % 60; if days > 0 { format!("{}d {}h", days, hours) } else if hours > 0 { format!("{}h {}m", hours, minutes) } else if minutes > 0 { format!("{}m {}s", minutes, secs) } else { format!("{}s", secs) } } fn format_date(timestamp: i64) -> String { if timestamp <= 0 { return "N/A".to_string(); } let dt = chrono::DateTime::from_timestamp(timestamp, 0); match dt { Some(dt) => dt.format("%d/%m/%Y %H:%M").to_string(), None => "N/A".to_string(), } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SortColumn { Name, Size, Progress, Status, DownSpeed, UpSpeed, ETA, AddedDate, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SortDirection { Ascending, Descending, } #[component] pub fn TorrentTable() -> impl IntoView { let store = use_context::().expect("store not provided"); let sort_col = create_rw_signal(SortColumn::AddedDate); let sort_dir = create_rw_signal(SortDirection::Descending); let filtered_torrents = move || { let mut torrents = store .torrents .get() .into_iter() .filter(|t| { let filter = store.filter.get(); let search = store.search_query.get().to_lowercase(); let matches_filter = match filter { crate::store::FilterStatus::All => true, crate::store::FilterStatus::Downloading => { t.status == shared::TorrentStatus::Downloading } crate::store::FilterStatus::Seeding => { t.status == shared::TorrentStatus::Seeding } crate::store::FilterStatus::Completed => { t.status == shared::TorrentStatus::Seeding || (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0) } // Approximate crate::store::FilterStatus::Paused => t.status == shared::TorrentStatus::Paused, crate::store::FilterStatus::Inactive => { t.status == shared::TorrentStatus::Paused || t.status == shared::TorrentStatus::Error } _ => true, }; let matches_search = if search.is_empty() { true } else { t.name.to_lowercase().contains(&search) }; matches_filter && matches_search }) .collect::>(); torrents.sort_by(|a, b| { let col = sort_col.get(); let dir = sort_dir.get(); let cmp = match col { SortColumn::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()), SortColumn::Size => a.size.cmp(&b.size), SortColumn::Progress => a .percent_complete .partial_cmp(&b.percent_complete) .unwrap_or(std::cmp::Ordering::Equal), SortColumn::Status => format!("{:?}", a.status).cmp(&format!("{:?}", b.status)), SortColumn::DownSpeed => a.down_rate.cmp(&b.down_rate), SortColumn::UpSpeed => a.up_rate.cmp(&b.up_rate), SortColumn::ETA => { let a_eta = if a.eta <= 0 { i64::MAX } else { a.eta }; let b_eta = if b.eta <= 0 { i64::MAX } else { b.eta }; a_eta.cmp(&b_eta) } SortColumn::AddedDate => a.added_date.cmp(&b.added_date), }; if dir == SortDirection::Descending { cmp.reverse() } else { cmp } }); torrents }; let handle_sort = move |col: SortColumn| { if sort_col.get() == col { sort_dir.update(|d| { *d = match d { SortDirection::Ascending => SortDirection::Descending, SortDirection::Descending => SortDirection::Ascending, } }); } else { sort_col.set(col); sort_dir.set(SortDirection::Ascending); } }; // Refs for click outside detection let sort_details_ref = create_node_ref::(); let _ = on_click_outside(sort_details_ref, move |_| { if let Some(el) = sort_details_ref.get_untracked() { el.set_open(false); } }); let sort_arrow = move |col: SortColumn| { if sort_col.get() == col { match sort_dir.get() { SortDirection::Ascending => { view! { "▲" }.into_view() } SortDirection::Descending => { view! { "▼" }.into_view() } } } else { view! { "▲" } .into_view() } }; let (selected_hash, set_selected_hash) = create_signal(Option::::None); let (menu_visible, set_menu_visible) = create_signal(false); let (menu_position, set_menu_position) = create_signal((0, 0)); let handle_context_menu = move |e: web_sys::MouseEvent, hash: String| { e.prevent_default(); set_menu_position.set((e.client_x(), e.client_y())); set_selected_hash.set(Some(hash)); // Select on right click too set_menu_visible.set(true); }; let on_action = move |(action, hash): (String, String)| { logging::log!("TorrentTable Action: {} on {}", action, hash); let (success_msg, error_msg) = get_action_messages(&action); let success_msg = success_msg.to_string(); let error_msg = error_msg.to_string(); let notifications = store.notifications; let hash = hash.clone(); let action = action.clone(); spawn_local(async move { let result = match action.as_str() { "delete" => api::torrent::delete(&hash).await, "delete_with_data" => api::torrent::delete_with_data(&hash).await, "start" => api::torrent::start(&hash).await, "stop" => api::torrent::stop(&hash).await, _ => api::torrent::action(&hash, &action).await, }; match result { Ok(_) => { logging::log!("Action {} executed successfully", action); show_toast_with_signal(notifications, NotificationLevel::Success, success_msg); } Err(e) => { logging::error!("Action failed: {:?}", e); show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: {:?}", error_msg, e)); } } }); }; view! {
"Torrents"
{move || filtered_torrents().into_iter().map(|t| { let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" }; let status_str = format!("{:?}", t.status); let status_badge_class = match t.status { shared::TorrentStatus::Seeding => "badge-success badge-soft", shared::TorrentStatus::Downloading => "badge-primary badge-soft", shared::TorrentStatus::Paused => "badge-warning badge-soft", shared::TorrentStatus::Error => "badge-error badge-soft", _ => "badge-ghost" }; let _t_hash = t.hash.clone(); let t_hash_click = t.hash.clone(); let t_hash_long = t.hash.clone(); let leptos_use::UseTimeoutFnReturn { start, stop, .. } = use_timeout_fn( move |pos: (i32, i32)| { set_menu_position.set(pos); set_selected_hash.set(Some(t_hash_long.clone())); set_menu_visible.set(true); // Haptic feedback let navigator = window().navigator(); if let Ok(vibrate) = js_sys::Reflect::get(&navigator, &"vibrate".into()) { if vibrate.is_function() { let _ = navigator.vibrate_with_duration(50); } } }, 600.0, ); let handle_touchstart = { let start = start.clone(); move |e: web_sys::TouchEvent| { if let Some(touch) = e.touches().get(0) { start((touch.client_x(), touch.client_y())); } } }; let handle_touchmove = { let stop = stop.clone(); move |_| stop() }; let handle_touchend = { let stop = stop.clone(); move |_| stop() }; let handle_touchcancel = move |_| stop(); view! {

{t.name}

{status_str}
{format_bytes(t.size)} {format!("{:.1}%", t.percent_complete)}
"Down" {format_speed(t.down_rate)}
"Up" {format_speed(t.up_rate)}
"ETA" {format_duration(t.eta)}
"Date" {format_date(t.added_date)}
} }).collect::>()}
} }