Compare commits

..

2 Commits

Author SHA1 Message Date
spinline
f1c75c468a fix: restore table alignment by ensuring proper HTML structure and integrating multi-select DataTable
All checks were successful
Build MIPS Binary / build (push) Successful in 5m27s
2026-02-12 00:48:35 +03:00
spinline
bfb152f0d8 feat: fully implement official DataTable with multi-selection support
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 00:46:36 +03:00
4 changed files with 132 additions and 40 deletions

View File

@@ -1,11 +1,14 @@
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;
fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
@@ -50,8 +53,11 @@ pub fn TorrentTable() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().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::<String>::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 +96,31 @@ pub fn TorrentTable() -> impl IntoView {
};
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| {
@@ -104,15 +134,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 (success_msg_str, error_msg_str): (&'static str, &'static str) = get_action_messages(&action);
let success_msg = success_msg_str.to_string();
@@ -141,38 +162,51 @@ pub fn TorrentTable() -> impl IntoView {
<DataTable>
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
<DataTableRow class="hover:bg-transparent">
<DataTableHead class="w-12">
<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)>
<div class="flex items-center">"Name" {move || sort_arrow(SortColumn::Name)}</div>
</DataTableHead>
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Size)>
<div class="flex items-center">"Size" {move || sort_arrow(SortColumn::Size)}</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>
<div class="flex items-center gap-2">
"Name"
<ArrowUpDown class="size-3 opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
</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>
</DataTableHeader>
<DataTableBody>
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
let on_action = on_action.clone();
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! {
<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 +214,13 @@ pub fn TorrentTable() -> impl IntoView {
</DataTable>
</div>
</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>
// --- MOBILE VIEW ---
@@ -205,6 +246,8 @@ pub fn TorrentTable() -> impl IntoView {
fn TorrentRow(
hash: String,
on_action: Callback<(String, String)>,
is_selected: Signal<bool>,
on_select: Callback<bool>,
) -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let h = hash.clone();
@@ -221,7 +264,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 +276,16 @@ fn TorrentRow(
view! {
<TorrentContextMenu torrent_hash=h_for_menu on_action=on_action.clone()>
<DataTableRow
class="cursor-pointer group"
attr:data-state=move || if is_selected.get() { "selected" } else { "" }
class="cursor-pointer group h-10"
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()))
>
<DataTableCell class="w-12">
<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>
{t_name_for_content}
</DataTableCell>
@@ -245,7 +294,7 @@ fn TorrentRow(
</DataTableCell>
<DataTableCell>
<div class="flex items-center gap-2">
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden">
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden min-w-[80px]">
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
</div>
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>

View 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>
}
}

View File

@@ -202,7 +202,7 @@ pub fn ContextMenuTrigger(
#[prop(optional)] on_open: Option<Callback<()>>,
) -> impl IntoView {
let ctx = expect_context::<ContextMenuContext>();
let trigger_class = tw_merge!("block w-full h-full", class);
let trigger_class = tw_merge!("contents", class);
view! {
<div

View File

@@ -6,4 +6,4 @@ pub mod context_menu;
pub mod theme_toggle;
pub mod svg_icon;
pub mod table;
pub mod data_table;
pub mod data_table;pub mod checkbox;