feat: resolve complex closure errors and finalize advanced DataTable features
Some checks failed
Build MIPS Binary / build (push) Failing after 1m30s
Some checks failed
Build MIPS Binary / build (push) Failing after 1m30s
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use std::collections::HashSet;
|
||||
use icons::{ArrowUpDown, Inbox};
|
||||
use icons::{ArrowUpDown, Inbox, Settings2, Play, Square, Trash2, Ellipsis};
|
||||
use crate::store::{get_action_messages, show_toast};
|
||||
use crate::api;
|
||||
use shared::NotificationLevel;
|
||||
@@ -9,8 +9,23 @@ 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};
|
||||
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
|
||||
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::*;
|
||||
|
||||
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"];
|
||||
@@ -56,9 +71,14 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
let sort_col = signal(SortColumn::AddedDate);
|
||||
let sort_dir = signal(SortDirection::Descending);
|
||||
|
||||
// Multi-selection state
|
||||
let selected_hashes = RwSignal::new(HashSet::<String>::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();
|
||||
@@ -116,11 +136,8 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
selected_hashes.update(|selected| {
|
||||
let hashes = filtered_hashes.get_untracked();
|
||||
for h in hashes {
|
||||
if checked {
|
||||
selected.insert(h);
|
||||
} else {
|
||||
selected.remove(&h);
|
||||
}
|
||||
if checked { selected.insert(h); }
|
||||
else { selected.remove(&h); }
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -138,14 +155,33 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
|
||||
let sort_icon = move |col: SortColumn| {
|
||||
let is_active = sort_col.0.get() == col;
|
||||
let class = if is_active {
|
||||
"size-3 opacity-100 text-primary"
|
||||
} else {
|
||||
"size-3 opacity-30 group-hover:opacity-100 transition-opacity"
|
||||
let class = if is_active { "size-3 text-primary" } else { "size-3 opacity-30 group-hover:opacity-100 transition-opacity" };
|
||||
view! { <ArrowUpDown class=class.to_string() /> }
|
||||
};
|
||||
view! {
|
||||
<ArrowUpDown class=class.to_string() />
|
||||
|
||||
let bulk_action = move |action: &'static str| {
|
||||
let hashes: Vec<String> = 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)| {
|
||||
@@ -168,15 +204,97 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="h-full bg-background relative flex flex-col overflow-hidden px-4 py-2">
|
||||
// --- DESKTOP VIEW ---
|
||||
<div class="hidden md:flex flex-col h-full overflow-hidden">
|
||||
<DataTableWrapper class="flex-1 min-h-0 bg-card/50">
|
||||
<div class="h-full bg-background relative flex flex-col overflow-hidden px-4 py-4 gap-4">
|
||||
// --- TOPBAR ---
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 flex-1 max-w-md">
|
||||
<Input
|
||||
class="h-9"
|
||||
placeholder="Torrent ara..."
|
||||
bind_value=store.search_query
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when=move || selected_count.get() > 0>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant=ButtonVariant::Secondary size=ButtonSize::Sm class="gap-2">
|
||||
<Ellipsis class="size-4" />
|
||||
{move || format!("Toplu İşlem ({})", selected_count.get())}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-48">
|
||||
<DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel>
|
||||
<DropdownMenuGroup class="mt-2">
|
||||
<DropdownMenuItem on:click=move |_| bulk_action("start")>
|
||||
<Play class="mr-2 size-4" /> "Başlat"
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem on:click=move |_| bulk_action("stop")>
|
||||
<Square class="mr-2 size-4" /> "Durdur"
|
||||
</DropdownMenuItem>
|
||||
|
||||
<div class="my-1 h-px bg-border" />
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger class="w-full">
|
||||
<DropdownMenuItem class="text-destructive focus:bg-destructive/10">
|
||||
<Trash2 class="mr-2 size-4" /> "Toplu Sil"
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>"Toplu Silme Onayı"</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{move || format!("Seçili {} torrent silinecek. Bu işlem geri alınamaz.", selected_count.get())}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogClose>"İptal"</AlertDialogClose>
|
||||
<Button variant=ButtonVariant::Destructive on:click=move |_| bulk_action("delete")>
|
||||
"Sil"
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Show>
|
||||
|
||||
<MultiSelect values=visible_columns>
|
||||
<MultiSelectTrigger class="w-[140px] h-9">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<Settings2 class="size-4" />
|
||||
"Sütunlar"
|
||||
</div>
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent>
|
||||
<MultiSelectGroup>
|
||||
{ALL_COLUMNS.into_iter().map(|(id, label)| {
|
||||
let id_val = id.to_string();
|
||||
view! {
|
||||
<MultiSelectItem>
|
||||
<MultiSelectOption value=id_val.clone() attr:disabled=move || id_val == "Name">
|
||||
{label}
|
||||
</MultiSelectOption>
|
||||
</MultiSelectItem>
|
||||
}.into_any()
|
||||
}).collect_view()}
|
||||
</MultiSelectGroup>
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// --- MAIN TABLE ---
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<DataTableWrapper class="h-full bg-card/50">
|
||||
<div class="h-full overflow-auto">
|
||||
<DataTable>
|
||||
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
||||
<DataTableRow class="hover:bg-transparent">
|
||||
<DataTableHead class="w-12">
|
||||
<DataTableHead class="w-12 px-4">
|
||||
<Checkbox
|
||||
checked=Signal::derive(move || {
|
||||
let hashes = filtered_hashes.get();
|
||||
@@ -186,38 +304,53 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
/>
|
||||
</DataTableHead>
|
||||
|
||||
// Column Headers with Sorting
|
||||
{move || visible_columns.get().contains("Name").then(|| view! {
|
||||
<DataTableHead class="cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
|
||||
<div class="flex items-center gap-2">"Name" {move || sort_icon(SortColumn::Name)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("Size").then(|| view! {
|
||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Size)>
|
||||
<div class="flex items-center gap-2">"Size" {move || sort_icon(SortColumn::Size)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("Progress").then(|| view! {
|
||||
<DataTableHead class="w-48 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Progress)>
|
||||
<div class="flex items-center gap-2">"Progress" {move || sort_icon(SortColumn::Progress)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("Status").then(|| view! {
|
||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Status)>
|
||||
<div class="flex items-center gap-2">"Status" {move || sort_icon(SortColumn::Status)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("DownSpeed").then(|| view! {
|
||||
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
||||
<div class="flex items-center justify-end gap-2">"DL Speed" {move || sort_icon(SortColumn::DownSpeed)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("UpSpeed").then(|| view! {
|
||||
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
||||
<div class="flex items-center justify-end gap-2">"UP Speed" {move || sort_icon(SortColumn::UpSpeed)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("ETA").then(|| view! {
|
||||
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::ETA)>
|
||||
<div class="flex items-center justify-end gap-2">"ETA" {move || sort_icon(SortColumn::ETA)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("AddedDate").then(|| view! {
|
||||
<DataTableHead class="w-32 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
||||
<div class="flex items-center justify-end gap-2">"Date" {move || sort_icon(SortColumn::AddedDate)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
</DataTableRow>
|
||||
</DataTableHeader>
|
||||
<DataTableBody>
|
||||
@@ -225,39 +358,25 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
when=move || !filtered_hashes.get().is_empty()
|
||||
fallback=move || view! {
|
||||
<DataTableRow class="hover:bg-transparent">
|
||||
<DataTableCell attr:colspan="9" class="h-[400px]">
|
||||
<DataTableCell attr:colspan="10" class="h-[400px]">
|
||||
<Empty class="h-full">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant=EmptyMediaVariant::Icon>
|
||||
<Inbox class="size-10" />
|
||||
<Inbox class="size-10 text-muted-foreground" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>"Torrent Bulunamadı"</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{move || {
|
||||
let query = store.search_query.get();
|
||||
if query.is_empty() {
|
||||
"Henüz eklenmiş bir torrent bulunmuyor.".to_string()
|
||||
} else {
|
||||
format!("'{}' araması için sonuç bulunamadı.", query)
|
||||
}
|
||||
if query.is_empty() { "Henüz torrent bulunmuyor.".to_string() }
|
||||
else { "Arama kriterlerinize uygun sonuç bulunamadı.".to_string() }
|
||||
}}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button
|
||||
variant=ButtonVariant::Outline
|
||||
on:click=move |_| {
|
||||
store.search_query.set(String::new());
|
||||
store.filter.set(crate::store::FilterStatus::All);
|
||||
}
|
||||
>
|
||||
"Tümünü Göster"
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
}
|
||||
}.into_any()
|
||||
>
|
||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
||||
let on_action = on_action.clone();
|
||||
@@ -272,6 +391,7 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
hash=hash.clone()
|
||||
on_action=on_action.clone()
|
||||
is_selected=is_selected
|
||||
visible_columns=visible_columns
|
||||
on_select=Callback::new(move |checked| {
|
||||
selected_hashes.update(|selected| {
|
||||
if checked { selected.insert(h_for_change.clone()); }
|
||||
@@ -287,44 +407,16 @@ 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 ---
|
||||
<div class="md:hidden flex flex-col h-full bg-muted/10 relative overflow-hidden">
|
||||
<div class="flex-1 overflow-y-auto p-3 min-h-0">
|
||||
<Show
|
||||
when=move || !filtered_hashes.get().is_empty()
|
||||
fallback=move || view! {
|
||||
<Empty class="h-64 mt-10">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant=EmptyMediaVariant::Icon>
|
||||
<Inbox class="size-10" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>"Boş Görünüyor"</EmptyTitle>
|
||||
<EmptyDescription>"Burada gösterilecek bir şey yok."</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
}
|
||||
>
|
||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
||||
let on_action = on_action.clone();
|
||||
move |hash| {
|
||||
view! {
|
||||
<div class="pb-3">
|
||||
<TorrentCard hash=hash.clone() on_action=on_action.clone() />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
} />
|
||||
<div class="hidden md:flex items-center justify-between px-2 py-1 text-[11px] text-muted-foreground bg-muted/20 border rounded-md">
|
||||
<div class="flex gap-4">
|
||||
<span>{move || format!("Toplam: {} torrent", filtered_hashes.get().len())}</span>
|
||||
<Show when=move || selected_count.get() > 0>
|
||||
<span class="text-primary font-medium">{move || format!("{} torrent seçili", selected_count.get())}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div>"VibeTorrent v3"</div>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
@@ -335,6 +427,7 @@ fn TorrentRow(
|
||||
hash: String,
|
||||
on_action: Callback<(String, String)>,
|
||||
is_selected: Signal<bool>,
|
||||
visible_columns: RwSignal<HashSet<String>>,
|
||||
on_select: Callback<bool>,
|
||||
) -> impl IntoView {
|
||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||
@@ -357,8 +450,6 @@ fn TorrentRow(
|
||||
selected.as_deref() == Some(stored_hash.get_value().as_str())
|
||||
});
|
||||
|
||||
let t_name_for_title = t_name.clone();
|
||||
let t_name_for_content = t_name.clone();
|
||||
let h_for_menu = stored_hash.get_value();
|
||||
|
||||
view! {
|
||||
@@ -368,41 +459,90 @@ fn TorrentRow(
|
||||
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">
|
||||
<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>
|
||||
{t_name_for_content}
|
||||
|
||||
{let t_name = t_name.clone();
|
||||
move || visible_columns.get().contains("Name").then({
|
||||
let t_name = t_name.clone();
|
||||
move || view! {
|
||||
<DataTableCell class="font-medium truncate max-w-[200px] lg:max-w-md" attr:title=t_name.clone()>
|
||||
{t_name.clone()}
|
||||
</DataTableCell>
|
||||
<DataTableCell class="font-mono text-xs text-muted-foreground">
|
||||
{format_bytes(t.size)}
|
||||
}
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("Size").then(|| {
|
||||
let size_str = format_bytes(t.size);
|
||||
view! {
|
||||
<DataTableCell class="font-mono text-xs text-muted-foreground whitespace-nowrap">
|
||||
{size_str}
|
||||
</DataTableCell>
|
||||
}
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("Progress").then(|| {
|
||||
let percent = t.percent_complete;
|
||||
view! {
|
||||
<DataTableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<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 class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", percent)></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>
|
||||
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", percent)}</span>
|
||||
</div>
|
||||
</DataTableCell>
|
||||
<DataTableCell class={format!("text-xs font-semibold {}", status_color)}>
|
||||
{format!("{:?}", t.status)}
|
||||
}
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("Status").then(|| {
|
||||
let status_str = format!("{:?}", t.status);
|
||||
view! {
|
||||
<DataTableCell class={format!("text-xs font-semibold whitespace-nowrap {}", status_color)}>
|
||||
{status_str}
|
||||
</DataTableCell>
|
||||
}
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("DownSpeed").then(|| {
|
||||
let speed_str = format_speed(t.down_rate);
|
||||
view! {
|
||||
<DataTableCell class="text-right font-mono text-xs text-green-600 dark:text-green-500 whitespace-nowrap">
|
||||
{format_speed(t.down_rate)}
|
||||
{speed_str}
|
||||
</DataTableCell>
|
||||
}
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("UpSpeed").then(|| {
|
||||
let speed_str = format_speed(t.up_rate);
|
||||
view! {
|
||||
<DataTableCell class="text-right font-mono text-xs text-blue-600 dark:text-blue-500 whitespace-nowrap">
|
||||
{format_speed(t.up_rate)}
|
||||
{speed_str}
|
||||
</DataTableCell>
|
||||
}
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("ETA").then(|| {
|
||||
let eta_str = format_duration(t.eta);
|
||||
view! {
|
||||
<DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
|
||||
{format_duration(t.eta)}
|
||||
{eta_str}
|
||||
</DataTableCell>
|
||||
}
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("AddedDate").then(|| {
|
||||
let date_str = format_date(t.added_date);
|
||||
view! {
|
||||
<DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
|
||||
{format_date(t.added_date)}
|
||||
{date_str}
|
||||
</DataTableCell>
|
||||
}
|
||||
}).into_any()}
|
||||
</DataTableRow>
|
||||
</TorrentContextMenu>
|
||||
}.into_any()
|
||||
|
||||
@@ -6,5 +6,12 @@ pub mod context_menu;
|
||||
pub mod theme_toggle;
|
||||
pub mod svg_icon;
|
||||
pub mod table;
|
||||
pub mod data_table;pub mod checkbox;
|
||||
pub mod data_table;
|
||||
pub mod checkbox;
|
||||
pub mod empty;
|
||||
pub mod multi_select;
|
||||
pub mod dropdown_menu;
|
||||
pub mod alert_dialog;
|
||||
pub mod dialog;
|
||||
pub mod select;
|
||||
pub mod separator;
|
||||
332
frontend/src/components/ui/select.rs
Normal file
332
frontend/src/components/ui/select.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
use icons::{Check, ChevronDown, ChevronUp};
|
||||
use leptos::context::Provider;
|
||||
use leptos::prelude::*;
|
||||
use leptos_ui::clx;
|
||||
use strum::{AsRefStr, Display};
|
||||
use tw_merge::*;
|
||||
|
||||
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
|
||||
use crate::components::hooks::use_random::use_random_id_for;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, AsRefStr)]
|
||||
pub enum SelectPosition {
|
||||
#[default]
|
||||
Below,
|
||||
Above,
|
||||
}
|
||||
|
||||
mod components {
|
||||
use super::*;
|
||||
clx! {SelectLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
|
||||
clx! {SelectItem, li, "inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4"}
|
||||
}
|
||||
|
||||
pub use components::*;
|
||||
|
||||
#[component]
|
||||
pub fn SelectGroup(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = "Select options".into(), into)] aria_label: String,
|
||||
) -> impl IntoView {
|
||||
let merged_class = tw_merge!("group", class);
|
||||
|
||||
view! {
|
||||
<ul data-name="SelectGroup" role="listbox" aria-label=aria_label class=merged_class>
|
||||
{children()}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
|
||||
let select_ctx = expect_context::<SelectContext>();
|
||||
|
||||
view! {
|
||||
<span data-name="SelectValue" class="text-sm text-muted-foreground truncate">
|
||||
{move || { select_ctx.value_signal.get().unwrap_or_else(|| placeholder.clone()) }}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
#[component]
|
||||
pub fn SelectOption(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = false.into(), into)] aria_selected: Signal<bool>,
|
||||
#[prop(optional, into)] value: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<SelectContext>();
|
||||
|
||||
let merged_class = tw_merge!(
|
||||
"group inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm cursor-pointer no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
class
|
||||
);
|
||||
|
||||
let value_for_check = value.clone();
|
||||
let is_selected = move || aria_selected.get() || ctx.value_signal.get() == value_for_check;
|
||||
|
||||
view! {
|
||||
<li
|
||||
data-name="SelectOption"
|
||||
class=merged_class
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected=move || is_selected().to_string()
|
||||
data-select-option="true"
|
||||
on:click=move |_| {
|
||||
let val = value.clone();
|
||||
ctx.value_signal.set(val.clone());
|
||||
if let Some(on_change) = ctx.on_change {
|
||||
on_change.run(val);
|
||||
}
|
||||
}
|
||||
>
|
||||
{children()}
|
||||
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-selected:opacity-100" />
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SelectContext {
|
||||
target_id: String,
|
||||
value_signal: RwSignal<Option<String>>,
|
||||
on_change: Option<Callback<Option<String>>>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Select(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional, into)] default_value: Option<String>,
|
||||
#[prop(optional)] on_change: Option<Callback<Option<String>>>,
|
||||
) -> impl IntoView {
|
||||
let select_target_id = use_random_id_for("select");
|
||||
let value_signal = RwSignal::new(default_value);
|
||||
|
||||
let ctx = SelectContext { target_id: select_target_id.clone(), value_signal, on_change };
|
||||
|
||||
let merged_class = tw_merge!("relative w-fit", class);
|
||||
|
||||
view! {
|
||||
<Provider value=ctx>
|
||||
<div data-name="Select" class=merged_class>
|
||||
{children()}
|
||||
</div>
|
||||
</Provider>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectTrigger(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional, into)] id: String,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<SelectContext>();
|
||||
|
||||
let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() };
|
||||
|
||||
let button_class = tw_merge!(
|
||||
"w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
|
||||
&peer_class,
|
||||
class
|
||||
);
|
||||
|
||||
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", ctx.target_id) };
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
data-name="SelectTrigger"
|
||||
class=button_class
|
||||
id=button_id
|
||||
tabindex="0"
|
||||
data-select-trigger=ctx.target_id
|
||||
>
|
||||
{children()}
|
||||
<ChevronDown class="text-muted-foreground" />
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectContent(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = SelectPosition::default())] position: SelectPosition,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<SelectContext>();
|
||||
|
||||
let merged_class = tw_merge!(
|
||||
"w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] left-0 data-[position=Above]:top-auto data-[position=Above]:bottom-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[state=closed]:data-[position=Below]:origin-top data-[state=open]:data-[position=Below]:origin-top data-[state=closed]:data-[position=Above]:origin-bottom data-[state=open]:data-[position=Above]:origin-bottom [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
class
|
||||
);
|
||||
|
||||
let target_id_for_script = ctx.target_id.clone();
|
||||
|
||||
// Scroll indicator signals
|
||||
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
||||
|
||||
view! {
|
||||
<script src="/hooks/lock_scroll.js"></script>
|
||||
|
||||
<div
|
||||
data-name="SelectContent"
|
||||
class=merged_class
|
||||
id=ctx.target_id
|
||||
data-target="target__select"
|
||||
data-state="closed"
|
||||
data-position=position.to_string()
|
||||
style="pointer-events: none;"
|
||||
on:scroll=on_scroll
|
||||
>
|
||||
<div
|
||||
data-scroll-up="true"
|
||||
class=move || {
|
||||
if can_scroll_up_signal.get() {
|
||||
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
}
|
||||
>
|
||||
<ChevronUp class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
{children()}
|
||||
<div
|
||||
data-scroll-down="true"
|
||||
class=move || {
|
||||
if can_scroll_down_signal.get() {
|
||||
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
}
|
||||
>
|
||||
<ChevronDown class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{format!(
|
||||
r#"
|
||||
(function() {{
|
||||
const setupSelect = () => {{
|
||||
const select = document.querySelector('#{}');
|
||||
const trigger = document.querySelector('[data-select-trigger="{}"]');
|
||||
|
||||
if (!select || !trigger) {{
|
||||
setTimeout(setupSelect, 50);
|
||||
return;
|
||||
}}
|
||||
|
||||
if (select.hasAttribute('data-initialized')) {{
|
||||
return;
|
||||
}}
|
||||
select.setAttribute('data-initialized', 'true');
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
const updatePosition = () => {{
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom;
|
||||
const spaceAbove = triggerRect.top;
|
||||
|
||||
// Determine if dropdown should go above or below
|
||||
if (spaceBelow < 200 && spaceAbove > spaceBelow) {{
|
||||
select.setAttribute('data-position', 'Above');
|
||||
}} else {{
|
||||
select.setAttribute('data-position', 'Below');
|
||||
}}
|
||||
|
||||
// Set min-width to match trigger
|
||||
select.style.minWidth = `${{triggerRect.width}}px`;
|
||||
}};
|
||||
|
||||
const openSelect = () => {{
|
||||
isOpen = true;
|
||||
|
||||
// Lock scrolling
|
||||
window.ScrollLock.lock();
|
||||
|
||||
// Update position and open
|
||||
updatePosition();
|
||||
select.setAttribute('data-state', 'open');
|
||||
select.style.pointerEvents = 'auto';
|
||||
|
||||
// Trigger scroll event to update indicators
|
||||
select.dispatchEvent(new Event('scroll'));
|
||||
|
||||
// Close on click outside
|
||||
setTimeout(() => {{
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}}, 0);
|
||||
}};
|
||||
|
||||
const closeSelect = () => {{
|
||||
isOpen = false;
|
||||
select.setAttribute('data-state', 'closed');
|
||||
select.style.pointerEvents = 'none';
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
|
||||
// Unlock scrolling after animation
|
||||
window.ScrollLock.unlock(200);
|
||||
}};
|
||||
|
||||
const handleClickOutside = (e) => {{
|
||||
if (!select.contains(e.target) && !trigger.contains(e.target)) {{
|
||||
closeSelect();
|
||||
}}
|
||||
}};
|
||||
|
||||
// Toggle select when trigger is clicked
|
||||
trigger.addEventListener('click', (e) => {{
|
||||
e.stopPropagation();
|
||||
if (isOpen) {{
|
||||
closeSelect();
|
||||
}} else {{
|
||||
openSelect();
|
||||
}}
|
||||
}});
|
||||
|
||||
// Close when option is selected
|
||||
const options = select.querySelectorAll('[data-select-option]');
|
||||
options.forEach(option => {{
|
||||
option.addEventListener('click', () => {{
|
||||
closeSelect();
|
||||
}});
|
||||
}});
|
||||
|
||||
// Handle ESC key to close
|
||||
document.addEventListener('keydown', (e) => {{
|
||||
if (e.key === 'Escape' && isOpen) {{
|
||||
e.preventDefault();
|
||||
closeSelect();
|
||||
}}
|
||||
}});
|
||||
}};
|
||||
|
||||
if (document.readyState === 'loading') {{
|
||||
document.addEventListener('DOMContentLoaded', setupSelect);
|
||||
}} else {{
|
||||
setupSelect();
|
||||
}}
|
||||
}})();
|
||||
"#,
|
||||
target_id_for_script,
|
||||
target_id_for_script,
|
||||
)}
|
||||
</script>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user