From bfb152f0d809680035974fcf144e143dd33a4aeb Mon Sep 17 00:00:00 2001 From: spinline Date: Thu, 12 Feb 2026 00:46:36 +0300 Subject: [PATCH] feat: fully implement official DataTable with multi-selection support --- frontend/src/components/torrent/table.rs | 126 ++++++++++++++++------- frontend/src/components/ui/checkbox.rs | 43 ++++++++ frontend/src/components/ui/mod.rs | 2 +- 3 files changed, 132 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/ui/checkbox.rs diff --git a/frontend/src/components/torrent/table.rs b/frontend/src/components/torrent/table.rs index 26aec13..93c8f7a 100644 --- a/frontend/src/components/torrent/table.rs +++ b/frontend/src/components/torrent/table.rs @@ -1,11 +1,15 @@ use leptos::prelude::*; use leptos::task::spawn_local; +use std::collections::HashSet; +use icons::{ArrowUpDown}; use crate::store::{get_action_messages, show_toast}; use crate::api; use shared::NotificationLevel; use crate::components::context_menu::TorrentContextMenu; use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent as CardBody}; use crate::components::ui::data_table::*; +use crate::components::ui::checkbox::Checkbox; +use crate::components::ui::button::{Button, ButtonVariant}; fn format_bytes(bytes: i64) -> String { const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"]; @@ -50,8 +54,11 @@ 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); + + // Multi-selection state + let selected_hashes = RwSignal::new(HashSet::::new()); - let filtered_hashes = Memo::new(move |_| { + let sorted_hashes_data = Memo::new(move |_| { let torrents_map = store.torrents.get(); let filter = store.filter.get(); let search = store.search_query.get(); @@ -90,7 +97,31 @@ pub fn TorrentTable() -> impl IntoView { }; if dir == SortDirection::Descending { cmp.reverse() } else { cmp } }); - torrents.into_iter().map(|t| t.hash.clone()).collect::>() + 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 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| { @@ -104,15 +135,6 @@ pub fn TorrentTable() -> impl IntoView { } }; - let sort_arrow = move |col: SortColumn| { - if sort_col.0.get() == col { - match sort_dir.0.get() { - SortDirection::Ascending => view! { "▲" }.into_any(), - SortDirection::Descending => view! { "▼" }.into_any(), - } - } else { view! { "▲" }.into_any() } - }; - 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(); @@ -141,38 +163,51 @@ pub fn TorrentTable() -> impl IntoView { + + + -
"Name" {move || sort_arrow(SortColumn::Name)}
-
- -
"Size" {move || sort_arrow(SortColumn::Size)}
-
- -
"Progress" {move || sort_arrow(SortColumn::Progress)}
-
- -
"Status" {move || sort_arrow(SortColumn::Status)}
-
- -
"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}
-
- -
"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}
-
- -
"ETA" {move || sort_arrow(SortColumn::ETA)}
-
- -
"Date" {move || sort_arrow(SortColumn::AddedDate)}
+
+ "Name" + +
+ "Size" + "Progress" + "Status" + "DL Speed" + "UP Speed" + "ETA" + "Date"
+ } } } /> @@ -180,6 +215,13 @@ pub fn TorrentTable() -> impl IntoView {
+ + // Selection Info Footer +
+
+ {move || format!("{} / {} torrent seçili", selected_count.get(), filtered_hashes.get().len())} +
+
// --- MOBILE VIEW --- @@ -205,6 +247,8 @@ pub fn TorrentTable() -> impl IntoView { fn TorrentRow( 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(); @@ -221,7 +265,7 @@ fn TorrentRow( let t_name = t.name.clone(); let status_color = match t.status { shared::TorrentStatus::Seeding => "text-green-500", shared::TorrentStatus::Downloading => "text-blue-500", shared::TorrentStatus::Paused => "text-yellow-500", shared::TorrentStatus::Error => "text-red-500", _ => "text-muted-foreground" }; - let is_selected = Memo::new(move |_| { + let is_active_selection = Memo::new(move |_| { let selected = store.selected_torrent.get(); selected.as_deref() == Some(stored_hash.get_value().as_str()) }); @@ -233,10 +277,16 @@ fn TorrentRow( view! { + + + {t_name_for_content} @@ -342,4 +392,4 @@ fn TorrentCard( } }.into_any() -} \ No newline at end of file +} diff --git a/frontend/src/components/ui/checkbox.rs b/frontend/src/components/ui/checkbox.rs new file mode 100644 index 0000000..bed739b --- /dev/null +++ b/frontend/src/components/ui/checkbox.rs @@ -0,0 +1,43 @@ +use icons::Check; +use leptos::prelude::*; +use tw_merge::tw_merge; + +#[component] +pub fn Checkbox( + #[prop(into, optional)] class: String, + #[prop(into, optional)] checked: Signal, + #[prop(into, optional)] disabled: Signal, + #[prop(into, optional)] on_checked_change: Option>, + #[prop(into, optional, default = "Checkbox".to_string())] aria_label: String, +) -> impl IntoView { + let checked_state = move || if checked.get() { "checked" } else { "unchecked" }; + + let checkbox_class = tw_merge!( + "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", + class + ); + + view! { + + } +} \ No newline at end of file diff --git a/frontend/src/components/ui/mod.rs b/frontend/src/components/ui/mod.rs index bfb240d..3b95cbd 100644 --- a/frontend/src/components/ui/mod.rs +++ b/frontend/src/components/ui/mod.rs @@ -6,4 +6,4 @@ pub mod context_menu; pub mod theme_toggle; pub mod svg_icon; pub mod table; -pub mod data_table; \ No newline at end of file +pub mod data_table;pub mod checkbox;