use leptos::*; use wasm_bindgen::closure::Closure; use wasm_bindgen::JsCast; use crate::store::{get_action_messages, show_toast_with_signal}; 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) } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SortColumn { Name, Size, Progress, Status, DownSpeed, UpSpeed, ETA, } #[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::Name); let sort_dir = create_rw_signal(SortDirection::Ascending); 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) } }; 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); } }; // Signal-based sort dropdown for mobile let (sort_open, set_sort_open) = create_signal(false); let sort_skip_close = store_value(false); let _ = window_event_listener(ev::click, move |_| { if sort_skip_close.get_value() { sort_skip_close.set_value(false); return; } set_sort_open.set(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); // Note: Don't close menu here - ContextMenu's on_close handles it // Closing here would dispose ContextMenu while still in callback chain // Get action messages for toast (Clean Code: DRY) let (success_msg, error_msg) = get_action_messages(&action); let success_msg = success_msg.to_string(); let error_msg = error_msg.to_string(); // Capture notifications signal before async (use_context unavailable in spawn_local) let notifications = store.notifications; spawn_local(async move { let action_req = if action == "delete_with_data" { "delete_with_data" } else { &action }; let req_body = shared::TorrentActionRequest { hash: hash.clone(), action: action_req.to_string(), }; let client = gloo_net::http::Request::post("/api/torrents/action").json(&req_body); match client { Ok(req) => match req.send().await { Ok(resp) => { if !resp.ok() { logging::error!( "Failed to execute action: {} {}", resp.status(), resp.status_text() ); show_toast_with_signal(notifications, NotificationLevel::Error, error_msg); } else { logging::log!("Action {} executed successfully", action); show_toast_with_signal(notifications, NotificationLevel::Success, success_msg); } } Err(e) => { logging::error!("Network error executing action: {}", e); show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: Bağlantı hatası", error_msg)); } }, Err(e) => { logging::error!("Failed to serialize request: {}", e); show_toast_with_signal(notifications, NotificationLevel::Error, error_msg); } } }); }; view! {
"Torrents"
"Sort"
{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 (timer_id, set_timer_id) = create_signal(Option::::None); let t_hash_long = t.hash.clone(); let clear_timer = move || { if let Some(id) = timer_id.get_untracked() { window().clear_timeout_with_handle(id); set_timer_id.set(None); } }; let handle_touchstart = { let t_hash = t_hash_long.clone(); move |e: web_sys::TouchEvent| { clear_timer(); if let Some(touch) = e.touches().get(0) { let x = touch.client_x(); let y = touch.client_y(); let hash = t_hash.clone(); let closure = Closure::wrap(Box::new(move || { set_menu_position.set((x, y)); set_selected_hash.set(Some(hash.clone())); set_menu_visible.set(true); // Haptic feedback (iOS Safari doesn't support vibrate) let navigator = window().navigator(); if js_sys::Reflect::has(&navigator, &wasm_bindgen::JsValue::from_str("vibrate")).unwrap_or(false) { let _ = navigator.vibrate_with_duration(50); } }) as Box); let id = window() .set_timeout_with_callback_and_timeout_and_arguments_0( closure.as_ref().unchecked_ref(), 600 ) .unwrap_or(0); closure.forget(); set_timer_id.set(Some(id)); } } }; let handle_touchmove = move |_| { clear_timer(); }; let handle_touchend = move |_| { clear_timer(); }; 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)}
} }).collect::>()}
} }