From f075a8766820b7869bc60a3445580dd199245d7d Mon Sep 17 00:00:00 2001 From: spinline Date: Fri, 20 Feb 2026 23:50:23 +0300 Subject: [PATCH] feat(ui): add bottom sheet and tabs for torrent details --- frontend/src/components/torrent/details.rs | 182 +++++++++++++++++++++ frontend/src/components/torrent/mod.rs | 1 + frontend/src/components/torrent/table.rs | 2 + frontend/src/components/ui/mod.rs | 1 + frontend/src/components/ui/tabs.rs | 108 ++++++++++++ 5 files changed, 294 insertions(+) create mode 100644 frontend/src/components/torrent/details.rs create mode 100644 frontend/src/components/ui/tabs.rs diff --git a/frontend/src/components/torrent/details.rs b/frontend/src/components/torrent/details.rs new file mode 100644 index 0000000..d62cbc5 --- /dev/null +++ b/frontend/src/components/torrent/details.rs @@ -0,0 +1,182 @@ +use leptos::prelude::*; +use crate::components::ui::sheet::*; +use crate::components::ui::tabs::*; +use crate::components::ui::skeleton::*; +use shared::Torrent; + +#[component] +pub fn TorrentDetailsSheet() -> impl IntoView { + let store = use_context::().expect("store not provided"); + + // Setup an effect to open the sheet when a torrent is selected + Effect::new(move |_| { + if store.selected_torrent.get().is_some() { + if let Some(trigger) = document().get_element_by_id("torrent-details-trigger") { + use wasm_bindgen::JsCast; + let _ = trigger.dyn_into::().map(|el| el.click()); + } + } + }); + + let selected_torrent = Memo::new(move |_| { + let hash = store.selected_torrent.get()?; + store.torrents.with(|map| map.get(&hash).cloned()) + }); + + view! { + + + +
+
+ }> +

+ {move || selected_torrent.get().unwrap().name} +

+
+ }> +

+ {move || format!("{:?}", selected_torrent.get().unwrap().status)} + {move || format!("{:.1}%", selected_torrent.get().unwrap().percent_complete)} +

+
+
+ // Custom close button that also resets store.selected_torrent + + + +
+ +
+ + + + "Genel" + + + "Dosyalar" + + + "İzleyiciler" + + + "Eşler" + + + +
+ + }> + {move || { + let t = selected_torrent.get().unwrap(); + view! { +
+ + + + + + +
+ } + }} +
+
+ + +
+ +

"Dosya listesi yakında eklenecek"

+
+
+ + +
+ +

"İzleyici listesi yakında eklenecek"

+
+
+ + +
+ +

"Eş listesi yakında eklenecek"

+
+
+
+
+
+
+
+ } +} + +#[component] +fn InfoItem( + label: &'static str, + value: String, + #[prop(optional)] class: &'static str +) -> impl IntoView { + view! { +
+ {label} + {value} +
+ } +} + +#[component] +fn DetailsShimmer() -> impl IntoView { + view! { +
+ {(0..8).map(|_| view! { +
+ + +
+ }).collect_view()} +
+ + +
+
+ + +
+
+ } +} + +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!("{}g {}s", days, hours) } + else if hours > 0 { format!("{}s {}d", hours, minutes) } + else if minutes > 0 { format!("{}d {}sn", minutes, secs) } + else { format!("{}sn", 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() } +} diff --git a/frontend/src/components/torrent/mod.rs b/frontend/src/components/torrent/mod.rs index 438f1c3..9cc07e8 100644 --- a/frontend/src/components/torrent/mod.rs +++ b/frontend/src/components/torrent/mod.rs @@ -1,2 +1,3 @@ pub mod table; pub mod add_torrent; +pub mod details; diff --git a/frontend/src/components/torrent/table.rs b/frontend/src/components/torrent/table.rs index 26e63fc..bcc9e37 100644 --- a/frontend/src/components/torrent/table.rs +++ b/frontend/src/components/torrent/table.rs @@ -553,6 +553,8 @@ pub fn TorrentTable() -> impl IntoView {
"VibeTorrent v3"
+ + }.into_any() } diff --git a/frontend/src/components/ui/mod.rs b/frontend/src/components/ui/mod.rs index 884bb2e..4ea3efe 100644 --- a/frontend/src/components/ui/mod.rs +++ b/frontend/src/components/ui/mod.rs @@ -20,5 +20,6 @@ pub mod skeleton; pub mod svg_icon; pub mod switch; pub mod table; +pub mod tabs; pub mod theme_toggle; pub mod toast; \ No newline at end of file diff --git a/frontend/src/components/ui/tabs.rs b/frontend/src/components/ui/tabs.rs new file mode 100644 index 0000000..b430f36 --- /dev/null +++ b/frontend/src/components/ui/tabs.rs @@ -0,0 +1,108 @@ +use leptos::context::Provider; +use leptos::prelude::*; +use tw_merge::tw_merge; + +#[derive(Clone)] +pub struct TabsContext { + pub active_tab: RwSignal, +} + +#[component] +pub fn Tabs( + #[prop(into)] default_value: String, + children: Children, + #[prop(optional, into)] class: String, +) -> impl IntoView { + let active_tab = RwSignal::new(default_value); + let ctx = TabsContext { active_tab }; + + let merged_class = tw_merge!("w-full", &class); + + view! { + +
+ {children()} +
+
+ } +} + +#[component] +pub fn TabsList( + children: Children, + #[prop(optional, into)] class: String, +) -> impl IntoView { + let merged_class = tw_merge!( + "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", + &class + ); + + view! { +
+ {children()} +
+ } +} + +#[component] +pub fn TabsTrigger( + #[prop(into)] value: String, + children: Children, + #[prop(optional, into)] class: String, +) -> impl IntoView { + let ctx = expect_context::(); + let v_clone = value.clone(); + + let is_active = Memo::new(move |_| ctx.active_tab.get() == v_clone); + + let merged_class = move || tw_merge!( + "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none", + if is_active.get() { + "bg-background text-foreground shadow-sm" + } else { + "hover:bg-background/50 hover:text-foreground" + }, + &class + ); + + view! { + + } +} + +#[component] +pub fn TabsContent( + #[prop(into)] value: String, + children: Children, + #[prop(optional, into)] class: String, +) -> impl IntoView { + let ctx = expect_context::(); + let v_clone = value.clone(); + + let is_active = Memo::new(move |_| ctx.active_tab.get() == v_clone); + + let merged_class = move || tw_merge!( + "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + if !is_active.get() { "hidden" } else { "" }, + &class + ); + + view! { +
+ {children()} +
+ } +}