feat: leptos-shadcn-tabs ile torrent detay paneli eklendi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
- Cargo.toml: leptos-shadcn-tabs ve leptos-shadcn-scroll-area eklendi - store.rs: selected_torrent sinyali eklendi (seçili torrent hash'i) - detail.rs: General, Transfer, Files, Peers tab'lı detay paneli oluşturuldu - table.rs: StoredValue ile satır tıklama ve seçili satır highlight - app.rs: TorrentDetail paneli TorrentTable altına entegre edildi
This commit is contained in:
32
Cargo.lock
generated
32
Cargo.lock
generated
@@ -1267,8 +1267,10 @@ dependencies = [
|
||||
"leptos-shadcn-context-menu",
|
||||
"leptos-shadcn-input",
|
||||
"leptos-shadcn-progress",
|
||||
"leptos-shadcn-scroll-area",
|
||||
"leptos-shadcn-separator",
|
||||
"leptos-shadcn-sheet",
|
||||
"leptos-shadcn-tabs",
|
||||
"leptos-use",
|
||||
"leptos_router",
|
||||
"log",
|
||||
@@ -2263,6 +2265,21 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-scroll-area"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef3d7bdcae4919ad495529ec2a5974036fb0b959580df310f36b2fd33f90860c"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-separator"
|
||||
version = "0.8.1"
|
||||
@@ -2307,6 +2324,21 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-tabs"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39f817c834e70a8359933b7b274564313be64105370611af96f05508541b661b"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-struct-component"
|
||||
version = "0.2.0"
|
||||
|
||||
@@ -44,3 +44,5 @@ leptos-shadcn-separator = "0.8"
|
||||
leptos-shadcn-progress = "0.8"
|
||||
leptos-shadcn-avatar = "0.8"
|
||||
leptos-shadcn-sheet = "0.8"
|
||||
leptos-shadcn-tabs = "0.8"
|
||||
leptos-shadcn-scroll-area = "0.8"
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::components::layout::protected::Protected;
|
||||
use crate::components::toast::ToastContainer;
|
||||
use crate::components::torrent::table::TorrentTable;
|
||||
use crate::components::torrent::detail::TorrentDetail;
|
||||
use crate::components::auth::login::Login;
|
||||
use crate::components::auth::setup::Setup;
|
||||
use leptos::prelude::*;
|
||||
@@ -124,7 +125,12 @@ pub fn App() -> impl IntoView {
|
||||
}.into_any()>
|
||||
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
||||
<Protected>
|
||||
<TorrentTable />
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<TorrentTable />
|
||||
</div>
|
||||
<TorrentDetail />
|
||||
</div>
|
||||
</Protected>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
156
frontend/src/components/torrent/detail.rs
Normal file
156
frontend/src/components/torrent/detail.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_shadcn_tabs::{Tabs, TabsList, TabsTrigger, TabsContent};
|
||||
|
||||
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_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() }
|
||||
}
|
||||
|
||||
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!("{}d {}h", days, hours) }
|
||||
else if hours > 0 { format!("{}h {}m", hours, minutes) }
|
||||
else if minutes > 0 { format!("{}m {}s", minutes, secs) }
|
||||
else { format!("{}s", secs) }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TorrentDetail() -> impl IntoView {
|
||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||
|
||||
let torrent = Memo::new(move |_| {
|
||||
let hash = store.selected_torrent.get()?;
|
||||
store.torrents.with(|map| map.get(&hash).cloned())
|
||||
});
|
||||
|
||||
let close = move |_| {
|
||||
store.selected_torrent.set(None);
|
||||
};
|
||||
|
||||
view! {
|
||||
<Show when=move || torrent.get().is_some()>
|
||||
{move || {
|
||||
let t = torrent.get().unwrap();
|
||||
let 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_text = format!("{:?}", t.status);
|
||||
|
||||
view! {
|
||||
<div class="border-t border-border bg-card flex flex-col" style="height: 280px; min-height: 200px;">
|
||||
// Header
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-border bg-muted/30">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<h3 class="text-sm font-semibold truncate">{name}</h3>
|
||||
<span class={format!("text-xs font-medium {}", status_color)}>{status_text}</span>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium hover:bg-accent hover:text-accent-foreground h-7 w-7 text-muted-foreground shrink-0"
|
||||
on:click=close
|
||||
title="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
// Tabs
|
||||
<Tabs default_value="general" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="px-4 pt-2">
|
||||
<TabsList class="w-full">
|
||||
<TabsTrigger value="general">"General"</TabsTrigger>
|
||||
<TabsTrigger value="transfer">"Transfer"</TabsTrigger>
|
||||
<TabsTrigger value="files">"Files"</TabsTrigger>
|
||||
<TabsTrigger value="peers">"Peers"</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="general" class="flex-1 overflow-y-auto px-4 pb-3">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-2 text-sm">
|
||||
<DetailItem label="Size" value=format_bytes(t.size) />
|
||||
<DetailItem label="Downloaded" value=format_bytes(t.completed) />
|
||||
<DetailItem label="Progress" value=format!("{:.1}%", t.percent_complete) />
|
||||
<DetailItem label="Added" value=format_date(t.added_date) />
|
||||
<DetailItem label="Hash" value={
|
||||
let hash = store.selected_torrent.get().unwrap_or_default();
|
||||
format!("{}…", &hash[..std::cmp::min(16, hash.len())])
|
||||
} />
|
||||
<DetailItem label="Label" value=t.label.clone().unwrap_or_else(|| "—".to_string()) />
|
||||
<DetailItem label="Error" value={
|
||||
if t.error_message.is_empty() { "None".to_string() } else { t.error_message.clone() }
|
||||
} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="transfer" class="flex-1 overflow-y-auto px-4 pb-3">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-2 text-sm">
|
||||
<DetailItem label="Download Speed" value=format_speed(t.down_rate) />
|
||||
<DetailItem label="Upload Speed" value=format_speed(t.up_rate) />
|
||||
<DetailItem label="ETA" value=format_duration(t.eta) />
|
||||
<DetailItem label="Downloaded" value=format_bytes(t.completed) />
|
||||
<DetailItem label="Total Size" value=format_bytes(t.size) />
|
||||
<DetailItem label="Remaining" value=format_bytes(t.size - t.completed) />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files" class="flex-1 overflow-y-auto px-4 pb-3">
|
||||
<div class="text-sm text-muted-foreground flex items-center gap-2 py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
||||
</svg>
|
||||
"File list will be available when file API is connected."
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="peers" class="flex-1 overflow-y-auto px-4 pb-3">
|
||||
<div class="text-sm text-muted-foreground flex items-center gap-2 py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||
</svg>
|
||||
"Peer list will be available when peer API is connected."
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn DetailItem(
|
||||
#[prop(into)] label: String,
|
||||
#[prop(into)] value: String,
|
||||
) -> impl IntoView {
|
||||
let title = value.clone();
|
||||
view! {
|
||||
<div class="flex flex-col gap-0.5 py-1">
|
||||
<span class="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{label}</span>
|
||||
<span class="text-foreground font-mono text-xs truncate" title=title>{value}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod table;
|
||||
pub mod add_torrent;
|
||||
pub mod detail;
|
||||
|
||||
@@ -210,6 +210,8 @@ fn TorrentRow(
|
||||
let h = hash.clone();
|
||||
let torrent = Memo::new(move |_| store.torrents.with(|map| map.get(&h).cloned()));
|
||||
|
||||
let stored_hash = StoredValue::new(hash.clone());
|
||||
|
||||
view! {
|
||||
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
||||
{
|
||||
@@ -219,7 +221,18 @@ fn TorrentRow(
|
||||
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" };
|
||||
|
||||
view! {
|
||||
<div class="flex items-center text-sm hover:bg-muted/50 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full">
|
||||
<div
|
||||
class=move || {
|
||||
let selected = store.selected_torrent.get();
|
||||
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
|
||||
if is_selected {
|
||||
"flex items-center text-sm bg-primary/10 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full"
|
||||
} else {
|
||||
"flex items-center text-sm hover:bg-muted/50 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full"
|
||||
}
|
||||
}
|
||||
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||
>
|
||||
<div class="flex-1 min-w-0 px-2 font-medium truncate" title=t_name.clone()>{t_name.clone()}</div>
|
||||
<div class="w-24 px-2 font-mono text-xs text-muted-foreground">{format_bytes(t.size)}</div>
|
||||
<div class="w-48 px-2">
|
||||
@@ -251,6 +264,8 @@ fn TorrentCard(
|
||||
let h = hash.clone();
|
||||
let torrent = Memo::new(move |_| store.torrents.with(|map| map.get(&h).cloned()));
|
||||
|
||||
let stored_hash = StoredValue::new(hash.clone());
|
||||
|
||||
view! {
|
||||
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
||||
{
|
||||
@@ -260,6 +275,18 @@ fn TorrentCard(
|
||||
let status_badge_class = match t.status { shared::TorrentStatus::Seeding => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border-green-200 dark:border-green-800", shared::TorrentStatus::Downloading => "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800", shared::TorrentStatus::Paused => "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800", shared::TorrentStatus::Error => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800", _ => "bg-muted text-muted-foreground" };
|
||||
|
||||
view! {
|
||||
<div
|
||||
class=move || {
|
||||
let selected = store.selected_torrent.get();
|
||||
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
|
||||
if is_selected {
|
||||
"ring-2 ring-primary rounded-lg transition-all"
|
||||
} else {
|
||||
"transition-all"
|
||||
}
|
||||
}
|
||||
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||
>
|
||||
<Card class="h-full select-none cursor-pointer hover:border-primary transition-colors">
|
||||
<CardHeader class="p-3 pb-0">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
@@ -285,6 +312,7 @@ fn TorrentCard(
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ pub struct TorrentStore {
|
||||
pub global_stats: RwSignal<GlobalStats>,
|
||||
pub notifications: RwSignal<Vec<NotificationItem>>,
|
||||
pub user: RwSignal<Option<String>>,
|
||||
pub selected_torrent: RwSignal<Option<String>>,
|
||||
}
|
||||
|
||||
pub fn provide_torrent_store() {
|
||||
@@ -78,10 +79,11 @@ pub fn provide_torrent_store() {
|
||||
let global_stats = RwSignal::new(GlobalStats::default());
|
||||
let notifications = RwSignal::new(Vec::<NotificationItem>::new());
|
||||
let user = RwSignal::new(Option::<String>::None);
|
||||
let selected_torrent = RwSignal::new(Option::<String>::None);
|
||||
|
||||
let show_browser_notification = crate::utils::notification::use_app_notification();
|
||||
|
||||
let store = TorrentStore { torrents, filter, search_query, global_stats, notifications, user };
|
||||
let store = TorrentStore { torrents, filter, search_query, global_stats, notifications, user, selected_torrent };
|
||||
provide_context(store);
|
||||
|
||||
let notifications_for_sse = notifications;
|
||||
|
||||
Reference in New Issue
Block a user