use leptos::prelude::*; use leptos::task::spawn_local; use wasm_bindgen::JsCast; use std::collections::HashSet; use icons::{ArrowUpDown, Inbox, Settings2, Play, Square, Trash2, Ellipsis, ArrowUp, ArrowDown, Check, ListFilter}; use crate::store::{get_action_messages, show_toast}; use crate::api; use shared::NotificationLevel; use crate::components::context_menu::TorrentContextMenu; use crate::components::ui::data_table::*; use crate::components::ui::checkbox::Checkbox; use crate::components::ui::badge::{Badge, BadgeVariant}; use crate::components::ui::button::{Button, ButtonVariant}; use crate::components::ui::empty::*; use crate::components::ui::input::Input; use crate::components::ui::multi_select::*; use crate::components::ui::dropdown_menu::*; use crate::components::ui::alert_dialog::{ AlertDialog, AlertDialogBody, AlertDialogClose, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, }; use tailwind_fuse::tw_merge; const ALL_COLUMNS: [(&str, &str); 8] = [ ("Name", "Name"), ("Size", "Size"), ("Progress", "Progress"), ("Status", "Status"), ("DownSpeed", "DL Speed"), ("UpSpeed", "UP Speed"), ("ETA", "ETA"), ("AddedDate", "Date"), ]; 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 = signal(SortColumn::AddedDate); let sort_dir = signal(SortDirection::Descending); let selected_hashes = RwSignal::new(HashSet::::new()); let visible_columns = RwSignal::new(HashSet::from([ "Name".to_string(), "Size".to_string(), "Progress".to_string(), "Status".to_string(), "DownSpeed".to_string(), "UpSpeed".to_string(), "ETA".to_string(), "AddedDate".to_string() ])); let sorted_hashes_data = Memo::new(move |_| { let torrents_map = store.torrents.get(); let filter = store.filter.get(); let search = store.search_query.get(); let search_lower = search.to_lowercase(); let mut torrents: Vec = torrents_map.values().filter(|t| { 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), 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_lower.is_empty() { true } else { t.name.to_lowercase().contains(&search_lower) }; matches_filter && matches_search }).cloned().collect(); torrents.sort_by(|a, b| { let col = sort_col.0.get(); let dir = sort_dir.0.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 filtered_hashes = Memo::new(move |_| { sorted_hashes_data.get().into_iter().map(|t| t.hash.clone()).collect::>() }); let selected_count = Signal::derive(move || { let current_hashes: HashSet = filtered_hashes.get().into_iter().collect(); selected_hashes.with(|selected| { selected.iter().filter(|h| current_hashes.contains(*h)).count() }) }); let has_selection = Signal::derive(move || selected_count.get() > 0); let handle_select_all = Callback::new(move |checked: bool| { selected_hashes.update(|selected| { let hashes = filtered_hashes.get_untracked(); for h in hashes { if checked { selected.insert(h); } else { selected.remove(&h); } } }); }); let handle_sort = move |col: SortColumn| { if sort_col.0.get() == col { sort_dir.1.update(|d| { *d = match d { SortDirection::Ascending => SortDirection::Descending, SortDirection::Descending => SortDirection::Ascending }; }); } else { sort_col.1.set(col); sort_dir.1.set(SortDirection::Ascending); } }; let sort_icon = move |col: SortColumn| { let is_active = sort_col.0.get() == col; let class = if is_active { "size-3 text-primary" } else { "size-3 opacity-30 group-hover:opacity-100 transition-opacity" }; view! { }.into_any() }; let bulk_action = move |action: &'static str| { let hashes: Vec = selected_hashes.get().into_iter().collect(); if hashes.is_empty() { return; } spawn_local(async move { let mut success = true; for hash in hashes { let res = match action { "start" => api::torrent::start(&hash).await, "stop" => api::torrent::stop(&hash).await, "delete" => api::torrent::delete(&hash).await, "delete_with_data" => api::torrent::delete_with_data(&hash).await, _ => Ok(()), }; if res.is_err() { success = false; } } if success { show_toast(NotificationLevel::Success, format!("Toplu işlem başarıyla tamamlandı: {}", action)); selected_hashes.update(|s| s.clear()); } else { show_toast(NotificationLevel::Error, "Bazı işlemler başarısız oldu."); } }); }; let on_action = Callback::new(move |(action, hash): (String, String)| { let (success_msg_str, error_msg_str): (&'static str, &'static str) = get_action_messages(&action); let success_msg = success_msg_str.to_string(); let error_msg = error_msg_str.to_string(); 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(_) => show_toast(NotificationLevel::Success, success_msg), Err(e) => show_toast(NotificationLevel::Error, format!("{}: {:?}", error_msg, e)), } }); }); view! {
// --- TOPBAR ---
{move || format!("Toplu İşlem ({})", selected_count.get())} "Seçili Torrentler" "Başlat" "Durdur"
// Trigger the hidden AlertDialog from this menu item ().map(|el: web_sys::HtmlElement| el.click()); } }> "Toplu Sil..." // Hidden AlertDialog moved outside the DropdownMenuContent to ensure proper centering "Toplu Silme Onayı" {move || format!("Seçili {} adet torrent silinecek. Lütfen silme yöntemini seçin:", selected_count.get())}
"⚠️ Dikkat: Verilerle birlikte silme işlemi dosyaları diskten de kalıcı olarak kaldıracaktır."
"Vazgeç"
// Mobile Sort Menu
"Sırala" "Sıralama Ölçütü" {move || { let current_col = sort_col.0.get(); let current_dir = sort_dir.0.get(); let sort_items = vec![ (SortColumn::Name, "İsim"), (SortColumn::Size, "Boyut"), (SortColumn::Progress, "İlerleme"), (SortColumn::Status, "Durum"), (SortColumn::DownSpeed, "DL Hızı"), (SortColumn::UpSpeed, "UP Hızı"), (SortColumn::ETA, "Kalan Süre"), (SortColumn::AddedDate, "Tarih"), ]; sort_items.into_iter().map(|(col, label)| { let is_active = current_col == col; view! {
{if is_active { view! { }.into_any() } else { view! {
}.into_any() }} {label}
{if is_active { match current_dir { SortDirection::Ascending => view! { }.into_any(), SortDirection::Descending => view! { }.into_any(), } } else { view! { "" }.into_any() }}
}.into_any() }).collect_view() }}
// Desktop Columns Menu
// --- MAIN CONTENT ---
// Desktop Table View // Mobile Card View

"Torrent Bulunamadı"

}.into_any() > } } } />
{move || format!("Toplam: {} torrent", filtered_hashes.get().len())} {move || format!("{} seçili", selected_count.get())}
"VibeTorrent v3"
}.into_any() } #[component] fn TorrentRow( hash: String, on_action: Callback<(String, String)>, is_selected: Signal, visible_columns: RwSignal>, on_select: Callback, ) -> impl IntoView { let store = use_context::().expect("store not provided"); let h = hash.clone(); let torrent = Memo::new(move |_| store.torrents.with(|map| map.get(&h).cloned())); let stored_hash = StoredValue::new(hash.clone()); view! { { let on_action = on_action.clone(); move || { let t = torrent.get().unwrap(); let t_name = t.name.clone(); let is_active_selection = Memo::new(move |_| { let selected = store.selected_torrent.get(); selected.as_deref() == Some(stored_hash.get_value().as_str()) }); let t_name_stored = StoredValue::new(t_name.clone()); let h_for_menu = stored_hash.get_value(); view! {
{move || visible_columns.get().contains("Name").then({ move || view! { {t_name_stored.get_value()} } }).into_any()} {move || visible_columns.get().contains("Size").then({ let size_bytes = t.size; move || { let size_str = format_bytes(size_bytes); view! { {size_str} } } }).into_any()} {move || visible_columns.get().contains("Progress").then({ let percent = t.percent_complete; move || view! {
{format!("{:.1}%", percent)}
} }).into_any()} {move || visible_columns.get().contains("Status").then({ let status_text = format!("{:?}", t.status); let variant = match t.status { shared::TorrentStatus::Seeding => BadgeVariant::Success, shared::TorrentStatus::Downloading => BadgeVariant::Info, shared::TorrentStatus::Paused => BadgeVariant::Warning, shared::TorrentStatus::Error => BadgeVariant::Destructive, _ => BadgeVariant::Secondary, }; move || view! { {status_text.clone()} } }).into_any()} {move || visible_columns.get().contains("DownSpeed").then({ let rate = t.down_rate; move || { let speed_str = format_speed(rate); view! { {speed_str} } } }).into_any()} {move || visible_columns.get().contains("UpSpeed").then({ let rate = t.up_rate; move || { let speed_str = format_speed(rate); view! { {speed_str} } } }).into_any()} {move || visible_columns.get().contains("ETA").then({ let eta = t.eta; move || { let eta_str = format_duration(eta); view! { {eta_str} } } }).into_any()} {move || visible_columns.get().contains("AddedDate").then({ let date = t.added_date; move || { let date_str = format_date(date); view! { {date_str} } } }).into_any()}
}.into_any() } }
}.into_any() } #[component] fn TorrentCard( hash: String, on_action: Callback<(String, String)>, is_selected: Signal, on_select: Callback, ) -> impl IntoView { let store = use_context::().expect("store not provided"); let h = hash.clone(); let torrent = Memo::new(move |_| store.torrents.with(|map| map.get(&h).cloned())); let stored_hash = StoredValue::new(hash.clone()); view! { { let on_action = on_action.clone(); move || { let t = torrent.get().unwrap(); let t_name = t.name.clone(); let status_variant = match t.status { shared::TorrentStatus::Seeding => BadgeVariant::Success, shared::TorrentStatus::Downloading => BadgeVariant::Info, shared::TorrentStatus::Paused => BadgeVariant::Warning, shared::TorrentStatus::Error => BadgeVariant::Destructive, _ => BadgeVariant::Secondary }; let h_for_menu = stored_hash.get_value(); view! {

{t_name.clone()}

{format!("{:?}", t.status)}
"Boyut:" {format_bytes(t.size)} {format!("{:.1}%", t.percent_complete)}
"İndirme" {format_speed(t.down_rate)}
"Gönderme" {format_speed(t.up_rate)}
"Kalan Süre" {format_duration(t.eta)}
"Eklenme" {format_date(t.added_date)}
}.into_any() } }
}.into_any() }