feat: fully implement official DataTable with multi-selection support
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
This commit is contained in:
@@ -1,11 +1,15 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use icons::{ArrowUpDown};
|
||||||
use crate::store::{get_action_messages, show_toast};
|
use crate::store::{get_action_messages, show_toast};
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use shared::NotificationLevel;
|
use shared::NotificationLevel;
|
||||||
use crate::components::context_menu::TorrentContextMenu;
|
use crate::components::context_menu::TorrentContextMenu;
|
||||||
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent as CardBody};
|
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent as CardBody};
|
||||||
use crate::components::ui::data_table::*;
|
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 {
|
fn format_bytes(bytes: i64) -> String {
|
||||||
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
@@ -50,8 +54,11 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
let sort_col = signal(SortColumn::AddedDate);
|
let sort_col = signal(SortColumn::AddedDate);
|
||||||
let sort_dir = signal(SortDirection::Descending);
|
let sort_dir = signal(SortDirection::Descending);
|
||||||
|
|
||||||
|
// Multi-selection state
|
||||||
|
let selected_hashes = RwSignal::new(HashSet::<String>::new());
|
||||||
|
|
||||||
let filtered_hashes = Memo::new(move |_| {
|
let sorted_hashes_data = Memo::new(move |_| {
|
||||||
let torrents_map = store.torrents.get();
|
let torrents_map = store.torrents.get();
|
||||||
let filter = store.filter.get();
|
let filter = store.filter.get();
|
||||||
let search = store.search_query.get();
|
let search = store.search_query.get();
|
||||||
@@ -90,7 +97,31 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
};
|
};
|
||||||
if dir == SortDirection::Descending { cmp.reverse() } else { cmp }
|
if dir == SortDirection::Descending { cmp.reverse() } else { cmp }
|
||||||
});
|
});
|
||||||
torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
|
torrents
|
||||||
|
});
|
||||||
|
|
||||||
|
let filtered_hashes = Memo::new(move |_| {
|
||||||
|
sorted_hashes_data.get().into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
|
||||||
|
});
|
||||||
|
|
||||||
|
let selected_count = Signal::derive(move || {
|
||||||
|
let current_hashes: HashSet<String> = 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| {
|
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! { <span class="ml-1 text-[10px]">"▲"</span> }.into_any(),
|
|
||||||
SortDirection::Descending => view! { <span class="ml-1 text-[10px]">"▼"</span> }.into_any(),
|
|
||||||
}
|
|
||||||
} else { view! { <span class="ml-1 text-[10px] opacity-0 group-hover:opacity-50 transition-opacity">"▲"</span> }.into_any() }
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_action = Callback::new(move |(action, hash): (String, String)| {
|
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_str, error_msg_str): (&'static str, &'static str) = get_action_messages(&action);
|
||||||
let success_msg = success_msg_str.to_string();
|
let success_msg = success_msg_str.to_string();
|
||||||
@@ -141,38 +163,51 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
<DataTable>
|
<DataTable>
|
||||||
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
||||||
<DataTableRow class="hover:bg-transparent">
|
<DataTableRow class="hover:bg-transparent">
|
||||||
|
<DataTableHead class="w-12 px-4">
|
||||||
|
<Checkbox
|
||||||
|
checked=Signal::derive(move || {
|
||||||
|
let hashes = filtered_hashes.get();
|
||||||
|
!hashes.is_empty() && selected_count.get() == hashes.len()
|
||||||
|
})
|
||||||
|
on_checked_change=handle_select_all
|
||||||
|
/>
|
||||||
|
</DataTableHead>
|
||||||
<DataTableHead class="cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
|
<DataTableHead class="cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
|
||||||
<div class="flex items-center">"Name" {move || sort_arrow(SortColumn::Name)}</div>
|
<div class="flex items-center gap-2">
|
||||||
</DataTableHead>
|
"Name"
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Size)>
|
<ArrowUpDown class="size-3 opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||||
<div class="flex items-center">"Size" {move || sort_arrow(SortColumn::Size)}</div>
|
</div>
|
||||||
</DataTableHead>
|
|
||||||
<DataTableHead class="w-48 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Progress)>
|
|
||||||
<div class="flex items-center">"Progress" {move || sort_arrow(SortColumn::Progress)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Status)>
|
|
||||||
<div class="flex items-center">"Status" {move || sort_arrow(SortColumn::Status)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
|
||||||
<div class="flex items-center">"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
|
||||||
<div class="flex items-center">"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::ETA)>
|
|
||||||
<div class="flex items-center">"ETA" {move || sort_arrow(SortColumn::ETA)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
<DataTableHead class="w-32 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
|
||||||
<div class="flex items-center">"Date" {move || sort_arrow(SortColumn::AddedDate)}</div>
|
|
||||||
</DataTableHead>
|
</DataTableHead>
|
||||||
|
<DataTableHead class="w-24">"Size"</DataTableHead>
|
||||||
|
<DataTableHead class="w-48">"Progress"</DataTableHead>
|
||||||
|
<DataTableHead class="w-24">"Status"</DataTableHead>
|
||||||
|
<DataTableHead class="w-24 text-right">"DL Speed"</DataTableHead>
|
||||||
|
<DataTableHead class="w-24 text-right">"UP Speed"</DataTableHead>
|
||||||
|
<DataTableHead class="w-24 text-right">"ETA"</DataTableHead>
|
||||||
|
<DataTableHead class="w-32 text-right">"Date"</DataTableHead>
|
||||||
</DataTableRow>
|
</DataTableRow>
|
||||||
</DataTableHeader>
|
</DataTableHeader>
|
||||||
<DataTableBody>
|
<DataTableBody>
|
||||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
||||||
let on_action = on_action.clone();
|
let on_action = on_action.clone();
|
||||||
move |hash| {
|
move |hash| {
|
||||||
|
let h = hash.clone();
|
||||||
|
let is_selected = Signal::derive(move || {
|
||||||
|
selected_hashes.with(|selected| selected.contains(&h))
|
||||||
|
});
|
||||||
|
let h_for_change = hash.clone();
|
||||||
view! {
|
view! {
|
||||||
<TorrentRow hash=hash.clone() on_action=on_action.clone() />
|
<TorrentRow
|
||||||
|
hash=hash.clone()
|
||||||
|
on_action=on_action.clone()
|
||||||
|
is_selected=is_selected
|
||||||
|
on_select=Callback::new(move |checked| {
|
||||||
|
selected_hashes.update(|selected| {
|
||||||
|
if checked { selected.insert(h_for_change.clone()); }
|
||||||
|
else { selected.remove(&h_for_change); }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} />
|
} />
|
||||||
@@ -180,6 +215,13 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</DataTableWrapper>
|
</DataTableWrapper>
|
||||||
|
|
||||||
|
// Selection Info Footer
|
||||||
|
<div class="flex items-center justify-between py-2 text-xs text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
{move || format!("{} / {} torrent seçili", selected_count.get(), filtered_hashes.get().len())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// --- MOBILE VIEW ---
|
// --- MOBILE VIEW ---
|
||||||
@@ -205,6 +247,8 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
fn TorrentRow(
|
fn TorrentRow(
|
||||||
hash: String,
|
hash: String,
|
||||||
on_action: Callback<(String, String)>,
|
on_action: Callback<(String, String)>,
|
||||||
|
is_selected: Signal<bool>,
|
||||||
|
on_select: Callback<bool>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
let h = hash.clone();
|
let h = hash.clone();
|
||||||
@@ -221,7 +265,7 @@ fn TorrentRow(
|
|||||||
let t_name = t.name.clone();
|
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 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();
|
let selected = store.selected_torrent.get();
|
||||||
selected.as_deref() == Some(stored_hash.get_value().as_str())
|
selected.as_deref() == Some(stored_hash.get_value().as_str())
|
||||||
});
|
});
|
||||||
@@ -233,10 +277,16 @@ fn TorrentRow(
|
|||||||
view! {
|
view! {
|
||||||
<TorrentContextMenu torrent_hash=h_for_menu on_action=on_action.clone()>
|
<TorrentContextMenu torrent_hash=h_for_menu on_action=on_action.clone()>
|
||||||
<DataTableRow
|
<DataTableRow
|
||||||
class="cursor-pointer group"
|
class="cursor-pointer group h-10"
|
||||||
attr:data-state=move || if is_selected.get() { "selected" } else { "" }
|
attr:data-state=move || if is_selected.get() || is_active_selection.get() { "selected" } else { "" }
|
||||||
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||||
>
|
>
|
||||||
|
<DataTableCell class="w-12 px-4">
|
||||||
|
<Checkbox
|
||||||
|
checked=is_selected
|
||||||
|
on_checked_change=on_select
|
||||||
|
/>
|
||||||
|
</DataTableCell>
|
||||||
<DataTableCell class="font-medium truncate max-w-[200px] lg:max-w-md" attr:title=t_name_for_title>
|
<DataTableCell class="font-medium truncate max-w-[200px] lg:max-w-md" attr:title=t_name_for_title>
|
||||||
{t_name_for_content}
|
{t_name_for_content}
|
||||||
</DataTableCell>
|
</DataTableCell>
|
||||||
@@ -342,4 +392,4 @@ fn TorrentCard(
|
|||||||
}
|
}
|
||||||
</Show>
|
</Show>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
}
|
}
|
||||||
|
|||||||
43
frontend/src/components/ui/checkbox.rs
Normal file
43
frontend/src/components/ui/checkbox.rs
Normal file
@@ -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<bool>,
|
||||||
|
#[prop(into, optional)] disabled: Signal<bool>,
|
||||||
|
#[prop(into, optional)] on_checked_change: Option<Callback<bool>>,
|
||||||
|
#[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! {
|
||||||
|
<button
|
||||||
|
data-name="Checkbox"
|
||||||
|
class=checkbox_class
|
||||||
|
data-state=checked_state
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked=move || checked.get().to_string()
|
||||||
|
aria-label=aria_label
|
||||||
|
disabled=move || disabled.get()
|
||||||
|
on:click=move |_| {
|
||||||
|
if !disabled.get() {
|
||||||
|
if let Some(callback) = on_checked_change {
|
||||||
|
callback.run(!checked.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span data-name="CheckboxIndicator" class="flex justify-center items-center text-current transition-none">
|
||||||
|
{move || { checked.get().then(|| view! { <Check class="size-3.5".to_string() /> }) }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,4 @@ pub mod context_menu;
|
|||||||
pub mod theme_toggle;
|
pub mod theme_toggle;
|
||||||
pub mod svg_icon;
|
pub mod svg_icon;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
pub mod data_table;
|
pub mod data_table;pub mod checkbox;
|
||||||
|
|||||||
Reference in New Issue
Block a user