Compare commits

...

5 Commits

Author SHA1 Message Date
spinline
ca1dd0caac refactor: tüm bileşenler leptos-shadcn-ui'ye dönüştürüldü
All checks were successful
Build MIPS Binary / build (push) Successful in 5m16s
- login.rs: Card, Input, Button, Label, Alert
- setup.rs: Card, Input, Button, Label, Alert
- add_torrent.rs: Dialog, Input, Button, Alert
- toast.rs: Alert bileşeni ile
- Cargo.toml: dialog, label, alert, toast, dropdown-menu, tooltip eklendi
2026-02-11 00:17:22 +03:00
spinline
ad336789d9 fix: custom × butonu kaldırıldı, native search clear kullanılıyor
All checks were successful
Build MIPS Binary / build (push) Successful in 5m14s
2026-02-11 00:11:28 +03:00
spinline
fa248d87ae fix: arama kutusundaki çift çarpı butonu düzeltildi (type=text)
All checks were successful
Build MIPS Binary / build (push) Successful in 5m16s
2026-02-10 23:59:40 +03:00
spinline
d8a9e9e137 feat: search kutusu leptos-shadcn-input ile güncellendi, sağa taşındı
All checks were successful
Build MIPS Binary / build (push) Successful in 5m18s
- toolbar.rs: search kutusu Input bileşeniyle değiştirildi
- Add Torrent butonu Button bileşeniyle güncellendi
- Search kutusu ortadan sağa taşındı
- Arama ikonu eklendi
2026-02-10 23:51:01 +03:00
spinline
ca31b4018f feat: leptos-shadcn-tabs ile torrent detay paneli eklendi
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
2026-02-10 23:45:21 +03:00
15 changed files with 521 additions and 191 deletions

130
Cargo.lock generated
View File

@@ -1260,15 +1260,23 @@ dependencies = [
"gloo-timers", "gloo-timers",
"js-sys", "js-sys",
"leptos", "leptos",
"leptos-shadcn-alert",
"leptos-shadcn-avatar", "leptos-shadcn-avatar",
"leptos-shadcn-badge", "leptos-shadcn-badge",
"leptos-shadcn-button", "leptos-shadcn-button",
"leptos-shadcn-card", "leptos-shadcn-card",
"leptos-shadcn-context-menu", "leptos-shadcn-context-menu",
"leptos-shadcn-dialog",
"leptos-shadcn-dropdown-menu",
"leptos-shadcn-input", "leptos-shadcn-input",
"leptos-shadcn-label",
"leptos-shadcn-progress", "leptos-shadcn-progress",
"leptos-shadcn-scroll-area",
"leptos-shadcn-separator", "leptos-shadcn-separator",
"leptos-shadcn-sheet", "leptos-shadcn-sheet",
"leptos-shadcn-tabs",
"leptos-shadcn-toast",
"leptos-shadcn-tooltip",
"leptos-use", "leptos-use",
"leptos_router", "leptos_router",
"log", "log",
@@ -2157,6 +2165,21 @@ dependencies = [
"send_wrapper", "send_wrapper",
] ]
[[package]]
name = "leptos-shadcn-alert"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "884551df61ade405bdb61b0d5a92a3a88b3a7af7a2d283b8d3e942ae0d71309c"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]] [[package]]
name = "leptos-shadcn-avatar" name = "leptos-shadcn-avatar"
version = "0.8.1" version = "0.8.1"
@@ -2232,6 +2255,36 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "leptos-shadcn-dialog"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3fdbb636393b150c2db1e37d44a6832e9dde177ce2e81281932fefad8bde98e"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-dropdown-menu"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f50189623a176386a30443d281483c5aa6cc34dc45fa11c3e53bd187ffccde21"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]] [[package]]
name = "leptos-shadcn-input" name = "leptos-shadcn-input"
version = "0.8.1" version = "0.8.1"
@@ -2248,6 +2301,21 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "leptos-shadcn-label"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e7cad4b5fae11df9bf3b1d4265a56509a9bb7d3a8580e7487f398b733eadf0c"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]] [[package]]
name = "leptos-shadcn-progress" name = "leptos-shadcn-progress"
version = "0.8.1" version = "0.8.1"
@@ -2263,6 +2331,21 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "leptos-shadcn-separator" name = "leptos-shadcn-separator"
version = "0.8.1" version = "0.8.1"
@@ -2307,6 +2390,53 @@ dependencies = [
"thiserror 1.0.69", "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-shadcn-toast"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3315f7ed844e3286704cc7b57db7209cad592c11eee770f5dc48ebdc92d66cfb"
dependencies = [
"gloo-timers",
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"uuid",
"web-sys",
]
[[package]]
name = "leptos-shadcn-tooltip"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e41d37932d700444e1d3a21f10f198c3c9e76dde3fd78d58da4b5a099939fd7"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]] [[package]]
name = "leptos-struct-component" name = "leptos-struct-component"
version = "0.2.0" version = "0.2.0"

View File

@@ -43,4 +43,12 @@ leptos-shadcn-context-menu = "0.8"
leptos-shadcn-separator = "0.8" leptos-shadcn-separator = "0.8"
leptos-shadcn-progress = "0.8" leptos-shadcn-progress = "0.8"
leptos-shadcn-avatar = "0.8" leptos-shadcn-avatar = "0.8"
leptos-shadcn-sheet = "0.8" leptos-shadcn-sheet = "0.8"
leptos-shadcn-tabs = "0.8"
leptos-shadcn-scroll-area = "0.8"
leptos-shadcn-dialog = "0.8"
leptos-shadcn-label = "0.8"
leptos-shadcn-alert = "0.8"
leptos-shadcn-toast = "0.8"
leptos-shadcn-dropdown-menu = "0.8"
leptos-shadcn-tooltip = "0.8"

View File

@@ -1,6 +1,7 @@
use crate::components::layout::protected::Protected; use crate::components::layout::protected::Protected;
use crate::components::toast::ToastContainer; use crate::components::toast::ToastContainer;
use crate::components::torrent::table::TorrentTable; use crate::components::torrent::table::TorrentTable;
use crate::components::torrent::detail::TorrentDetail;
use crate::components::auth::login::Login; use crate::components::auth::login::Login;
use crate::components::auth::setup::Setup; use crate::components::auth::setup::Setup;
use leptos::prelude::*; use leptos::prelude::*;
@@ -124,7 +125,12 @@ pub fn App() -> impl IntoView {
}.into_any()> }.into_any()>
<Show when=move || is_authenticated.0.get() fallback=|| ()> <Show when=move || is_authenticated.0.get() fallback=|| ()>
<Protected> <Protected>
<TorrentTable /> <div class="flex flex-col h-full overflow-hidden">
<div class="flex-1 overflow-hidden">
<TorrentTable />
</div>
<TorrentDetail />
</div>
</Protected> </Protected>
</Show> </Show>
</Show> </Show>

View File

@@ -1,5 +1,10 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use leptos_shadcn_card::{Card, CardHeader, CardContent};
use leptos_shadcn_input::Input;
use leptos_shadcn_button::Button;
use leptos_shadcn_label::Label;
use leptos_shadcn_alert::{Alert, AlertDescription, AlertVariant};
#[component] #[component]
pub fn Login() -> impl IntoView { pub fn Login() -> impl IntoView {
@@ -32,8 +37,8 @@ pub fn Login() -> impl IntoView {
view! { view! {
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4"> <div class="flex items-center justify-center min-h-screen bg-muted/40 px-4">
<div class="w-full max-w-sm rounded-xl border border-border bg-card text-card-foreground shadow-lg"> <Card class="w-full max-w-sm shadow-lg">
<div class="flex flex-col space-y-1.5 p-6 pb-2 items-center"> <CardHeader class="pb-2 items-center">
<div class="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-sm mb-4"> <div class="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-sm mb-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.25 0 003 2.48z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.25 0 003 2.48z" />
@@ -42,56 +47,53 @@ pub fn Login() -> impl IntoView {
</div> </div>
<h3 class="font-semibold tracking-tight text-2xl">"VibeTorrent"</h3> <h3 class="font-semibold tracking-tight text-2xl">"VibeTorrent"</h3>
<p class="text-sm text-muted-foreground">"Hesabınıza giriş yapın"</p> <p class="text-sm text-muted-foreground">"Hesabınıza giriş yapın"</p>
</div> </CardHeader>
<div class="p-6 pt-4"> <CardContent class="pt-4">
<form on:submit=handle_login class="space-y-4"> <form on:submit=handle_login class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium leading-none">"Kullanıcı Adı"</label> <Label>"Kullanıcı Adı"</Label>
<input <Input
type="text" input_type="text"
placeholder="Kullanıcı adınız" placeholder="Kullanıcı adınız"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50" value=MaybeProp::derive(move || Some(username.0.get()))
prop:value=move || username.0.get() on_change=Callback::new(move |val: String| username.1.set(val))
on:input=move |ev| username.1.set(event_target_value(&ev)) disabled=Signal::derive(move || loading.0.get())
disabled=move || loading.0.get()
required
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium leading-none">"Şifre"</label> <Label>"Şifre"</Label>
<input <Input
type="password" input_type="password"
placeholder="******" placeholder="******"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50" value=MaybeProp::derive(move || Some(password.0.get()))
prop:value=move || password.0.get() on_change=Callback::new(move |val: String| password.1.set(val))
on:input=move |ev| password.1.set(event_target_value(&ev)) disabled=Signal::derive(move || loading.0.get())
disabled=move || loading.0.get()
required
/> />
</div> </div>
<Show when=move || error.0.get().is_some()> <Show when=move || error.0.get().is_some()>
<div class="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <Alert variant=AlertVariant::Destructive>
{move || error.0.get().unwrap_or_default()} <AlertDescription>
</div> {move || error.0.get().unwrap_or_default()}
</AlertDescription>
</Alert>
</Show> </Show>
<div class="pt-2"> <div class="pt-2">
<button <Button
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2 w-full" class="w-full"
type="submit" disabled=Signal::derive(move || loading.0.get())
disabled=move || loading.0.get()
> >
<Show when=move || loading.0.get() fallback=|| "Giriş Yap"> <Show when=move || loading.0.get() fallback=|| "Giriş Yap">
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span> <span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
"Giriş Yapılıyor..." "Giriş Yapılıyor..."
</Show> </Show>
</button> </Button>
</div> </div>
</form> </form>
</div> </CardContent>
</div> </Card>
</div> </div>
} }
} }

View File

@@ -1,5 +1,10 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use leptos_shadcn_card::{Card, CardHeader, CardContent};
use leptos_shadcn_input::Input;
use leptos_shadcn_button::Button;
use leptos_shadcn_label::Label;
use leptos_shadcn_alert::{Alert, AlertDescription, AlertVariant};
#[component] #[component]
pub fn Setup() -> impl IntoView { pub fn Setup() -> impl IntoView {
@@ -48,8 +53,8 @@ pub fn Setup() -> impl IntoView {
view! { view! {
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4"> <div class="flex items-center justify-center min-h-screen bg-muted/40 px-4">
<div class="w-full max-w-md rounded-xl border border-border bg-card text-card-foreground shadow-lg overflow-hidden"> <Card class="w-full max-w-md shadow-lg overflow-hidden">
<div class="flex flex-col space-y-1.5 p-6 pb-2 items-center text-center"> <CardHeader class="pb-2 items-center text-center">
<div class="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-sm mb-4"> <div class="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-sm mb-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-3.497a2.548 2.548 0 113.586 3.586l-6.837 5.63m-5.108 3.497l2.496-3.03c.317-.384.74-.626 1.208-.766M15.75 9.25a2.548 2.548 0 11-5.096 0 2.548 2.548 0 015.096 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-3.497a2.548 2.548 0 113.586 3.586l-6.837 5.63m-5.108 3.497l2.496-3.03c.317-.384.74-.626 1.208-.766M15.75 9.25a2.548 2.548 0 11-5.096 0 2.548 2.548 0 015.096 0z" />
@@ -57,74 +62,63 @@ pub fn Setup() -> impl IntoView {
</div> </div>
<h3 class="font-semibold tracking-tight text-2xl">"VibeTorrent Kurulumu"</h3> <h3 class="font-semibold tracking-tight text-2xl">"VibeTorrent Kurulumu"</h3>
<p class="text-sm text-muted-foreground">"Yönetici hesabınızı oluşturun"</p> <p class="text-sm text-muted-foreground">"Yönetici hesabınızı oluşturun"</p>
</div> </CardHeader>
<div class="p-6 pt-4"> <CardContent class="pt-4">
<form on:submit=handle_setup class="space-y-4"> <form on:submit=handle_setup class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> <Label>"Yönetici Kullanıcı Adı"</Label>
"Yönetici Kullanıcı Adı" <Input
</label> input_type="text"
<input placeholder="admin"
type="text" value=MaybeProp::derive(move || Some(username.0.get()))
placeholder="admin" on_change=Callback::new(move |val: String| username.1.set(val))
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50" disabled=Signal::derive(move || loading.0.get())
prop:value=move || username.0.get()
on:input=move |ev| username.1.set(event_target_value(&ev))
disabled=move || loading.0.get()
required
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> <Label>"Şifre"</Label>
"Şifre" <Input
</label> input_type="password"
<input placeholder="******"
type="password" value=MaybeProp::derive(move || Some(password.0.get()))
placeholder="******" on_change=Callback::new(move |val: String| password.1.set(val))
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50" disabled=Signal::derive(move || loading.0.get())
prop:value=move || password.0.get()
on:input=move |ev| password.1.set(event_target_value(&ev))
disabled=move || loading.0.get()
required
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> <Label>"Şifre Onay"</Label>
"Şifre Onay" <Input
</label> input_type="password"
<input placeholder="******"
type="password" value=MaybeProp::derive(move || Some(confirm_password.0.get()))
placeholder="******" on_change=Callback::new(move |val: String| confirm_password.1.set(val))
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50" disabled=Signal::derive(move || loading.0.get())
prop:value=move || confirm_password.0.get()
on:input=move |ev| confirm_password.1.set(event_target_value(&ev))
disabled=move || loading.0.get()
required
/> />
</div> </div>
<Show when=move || error.0.get().is_some() fallback=|| ()> <Show when=move || error.0.get().is_some() fallback=|| ()>
<div class="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive dark:border-destructive dark:bg-destructive/50 dark:text-destructive-foreground"> <Alert variant=AlertVariant::Destructive>
<span>{move || error.0.get().unwrap_or_default()}</span> <AlertDescription>
</div> <span>{move || error.0.get().unwrap_or_default()}</span>
</AlertDescription>
</Alert>
</Show> </Show>
<div class="pt-2"> <div class="pt-2">
<button <Button
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2 w-full" class="w-full"
type="submit" disabled=Signal::derive(move || loading.0.get())
disabled=move || loading.0.get()
> >
<Show when=move || loading.0.get() fallback=|| "Kurulumu Tamamla"> <Show when=move || loading.0.get() fallback=|| "Kurulumu Tamamla">
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span> <span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
"Kuruluyor..." "Kuruluyor..."
</Show> </Show>
</button> </Button>
</div> </div>
</form> </form>
</div> </CardContent>
</div> </Card>
</div> </div>
} }
} }

View File

@@ -1,4 +1,6 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos_shadcn_input::Input;
use leptos_shadcn_button::{Button, ButtonVariant, ButtonSize};
use crate::components::torrent::add_torrent::AddTorrentDialog; use crate::components::torrent::add_torrent::AddTorrentDialog;
#[component] #[component]
@@ -9,54 +11,51 @@ pub fn Toolbar() -> impl IntoView {
view! { view! {
<div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);"> <div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);">
<div class="flex flex-1 items-center gap-4"> // Sol kısım: Menü butonu + Add Torrent
<div class="flex items-center gap-3">
// Mobile Menu Trigger // Mobile Menu Trigger
<button <Button
on:click=move |_| is_mobile_menu_open.update(|v| *v = !*v) variant=ButtonVariant::Ghost
class="lg:hidden inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 w-10" size=ButtonSize::Icon
class="lg:hidden"
on_click=Callback::new(move |()| is_mobile_menu_open.update(|v| *v = !*v))
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</button> </Button>
<div class="flex items-center gap-3"> <Button
<button class="gap-2 shadow"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 shadow gap-2" on_click=Callback::new(move |()| show_add_modal.1.set(true))
on:click=move |_| show_add_modal.1.set(true) >
> <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 md:w-5 md:h-5">
<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 md:w-5 md:h-5"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> </svg>
<span class="hidden sm:inline">"Add Torrent"</span>
<span class="sm:hidden">"Add"</span>
</Button>
</div>
// Sağ kısım: Search kutusu
<div class="flex flex-1 items-center justify-end gap-2">
<div class="hidden md:flex items-center gap-2 w-full max-w-xs">
<div class="relative flex-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg> </svg>
<span class="hidden sm:inline">"Add Torrent"</span> <Input
<span class="sm:hidden">"Add"</span> input_type="search"
</button> placeholder="Search..."
value=MaybeProp::derive(move || Some(store.search_query.get()))
on_change=Callback::new(move |val: String| store.search_query.set(val))
class="pl-8 h-9"
/>
</div>
</div> </div>
</div> </div>
<div class="hidden md:flex items-center justify-center flex-1"> <Show when=move || show_add_modal.0.get()>
<div class="relative w-full max-w-sm"> <AddTorrentDialog on_close=Callback::new(move |()| show_add_modal.1.set(false)) />
<input </Show>
type="text"
placeholder="Search..."
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
prop:value=move || store.search_query.get()
on:input=move |ev| store.search_query.set(event_target_value(&ev))
/>
<Show when=move || !store.search_query.get().is_empty()>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full text-xs font-medium hover:bg-muted h-5 w-5 opacity-50 hover:opacity-100 transition-opacity"
on:click=move |_| store.search_query.set(String::new())
>
"×"
</button>
</Show>
</div>
</div>
<div class="flex flex-1 justify-end px-4 gap-2">
<Show when=move || show_add_modal.0.get()>
<AddTorrentDialog on_close=Callback::new(move |()| show_add_modal.1.set(false)) />
</Show>
</div>
</div> </div>
} }
} }

View File

@@ -1,30 +1,22 @@
use leptos::prelude::*; use leptos::prelude::*;
use shared::NotificationLevel; use shared::NotificationLevel;
use leptos_shadcn_alert::{Alert, AlertVariant};
// ============================================================================ // ============================================================================
// Toast Components - Shadcn Style // Toast Components - Using ShadCN Alert
// ============================================================================ // ============================================================================
/// Returns the Shadcn class for the notification level fn level_to_variant(level: &NotificationLevel) -> AlertVariant {
fn get_toast_class(level: &NotificationLevel) -> &'static str {
match level { match level {
NotificationLevel::Info => "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 shadow-lg transition-all bg-background text-foreground border-border", NotificationLevel::Info => AlertVariant::Default,
NotificationLevel::Success => "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 shadow-lg transition-all bg-background text-foreground border-primary/50 text-primary", NotificationLevel::Success => AlertVariant::Success,
NotificationLevel::Warning => "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 shadow-lg transition-all bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-500 border-yellow-200 dark:border-yellow-900", NotificationLevel::Warning => AlertVariant::Warning,
NotificationLevel::Error => "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 shadow-lg transition-all destructive group border-destructive bg-destructive text-destructive-foreground", NotificationLevel::Error => AlertVariant::Destructive,
} }
} }
/// Individual toast item component fn level_icon(level: &NotificationLevel) -> impl IntoView {
#[component] match level {
fn ToastItem(
level: NotificationLevel,
message: String,
) -> impl IntoView {
let toast_class = get_toast_class(&level);
// Icons
let icon_svg = match level {
NotificationLevel::Info => view! { NotificationLevel::Info => view! {
<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 opacity-90"> <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 opacity-90">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
@@ -45,15 +37,25 @@ fn ToastItem(
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg> </svg>
}.into_any(), }.into_any(),
}; }
}
/// Individual toast item component
#[component]
fn ToastItem(
level: NotificationLevel,
message: String,
) -> impl IntoView {
let variant = level_to_variant(&level);
let icon = level_icon(&level);
view! { view! {
<div class=toast_class> <Alert variant=variant class="pointer-events-auto shadow-lg">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{icon_svg} {icon}
<div class="text-sm font-medium">{message}</div> <div class="text-sm font-medium">{message}</div>
</div> </div>
</div> </Alert>
} }
} }

View File

@@ -1,6 +1,9 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::html;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use leptos_shadcn_dialog::{Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter};
use leptos_shadcn_input::Input;
use leptos_shadcn_button::{Button, ButtonVariant};
use leptos_shadcn_alert::{Alert, AlertDescription, AlertVariant};
use crate::store::TorrentStore; use crate::store::TorrentStore;
use crate::api; use crate::api;
@@ -11,16 +14,10 @@ pub fn AddTorrentDialog(
let store = use_context::<TorrentStore>().expect("TorrentStore not provided"); let store = use_context::<TorrentStore>().expect("TorrentStore not provided");
let notifications = store.notifications; let notifications = store.notifications;
let dialog_ref = NodeRef::<html::Dialog>::new();
let uri = signal(String::new()); let uri = signal(String::new());
let is_loading = signal(false); let is_loading = signal(false);
let error_msg = signal(Option::<String>::None); let error_msg = signal(Option::<String>::None);
let is_open = signal(true);
Effect::new(move |_| {
if let Some(dialog) = dialog_ref.get() {
let _ = dialog.show_modal();
}
});
let handle_submit = move |ev: web_sys::SubmitEvent| { let handle_submit = move |ev: web_sys::SubmitEvent| {
ev.prevent_default(); ev.prevent_default();
@@ -44,9 +41,7 @@ pub fn AddTorrentDialog(
shared::NotificationLevel::Success, shared::NotificationLevel::Success,
"Torrent başarıyla eklendi" "Torrent başarıyla eklendi"
); );
if let Some(dialog) = dialog_ref.get() { is_open.1.set(false);
dialog.close();
}
on_close.run(()); on_close.run(());
} }
Err(e) => { Err(e) => {
@@ -58,51 +53,58 @@ pub fn AddTorrentDialog(
}); });
}; };
let handle_cancel = move |_| {
if let Some(dialog) = dialog_ref.get() {
dialog.close();
}
on_close.run(());
};
view! { view! {
<dialog node_ref=dialog_ref class="modal modal-bottom sm:modal-middle"> <Dialog
<div class="modal-box"> open=Signal::derive(move || is_open.0.get())
<h3 class="font-bold text-lg">"Add Torrent"</h3> on_open_change=Callback::new(move |open: bool| {
<p class="py-4 text-sm opacity-70">"Enter a Magnet link or a .torrent file URL."</p> is_open.1.set(open);
if !open {
on_close.run(());
}
})
>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>"Add Torrent"</DialogTitle>
<DialogDescription>"Enter a Magnet link or a .torrent file URL."</DialogDescription>
</DialogHeader>
<form on:submit=handle_submit> <form on:submit=handle_submit class="space-y-4">
<div class="form-control w-full"> <Input
<input input_type="text"
type="text" placeholder="magnet:?xt=urn:btih:..."
placeholder="magnet:?xt=urn:btih:..." value=MaybeProp::derive(move || Some(uri.0.get()))
class="input input-bordered w-full" on_change=Callback::new(move |val: String| uri.1.set(val))
prop:value=move || uri.0.get() disabled=Signal::derive(move || is_loading.0.get())
on:input=move |ev| uri.1.set(event_target_value(&ev)) />
disabled=move || is_loading.0.get()
autofocus
/>
</div>
<div class="modal-action"> {move || error_msg.0.get().map(|msg| view! {
<button type="button" class="btn btn-ghost" on:click=handle_cancel>"Cancel"</button> <Alert variant=AlertVariant::Destructive>
<button type="submit" class="btn btn-primary" disabled=move || is_loading.0.get()> <AlertDescription>{msg}</AlertDescription>
</Alert>
})}
<DialogFooter>
<Button
variant=ButtonVariant::Ghost
on_click=Callback::new(move |()| {
is_open.1.set(false);
on_close.run(());
})
>
"Cancel"
</Button>
<Button disabled=Signal::derive(move || is_loading.0.get())>
{move || if is_loading.0.get() { {move || if is_loading.0.get() {
leptos::either::Either::Left(view! { <span class="loading loading-spinner"></span> "Adding..." }) leptos::either::Either::Left(view! { <span class="loading loading-spinner"></span> "Adding..." })
} else { } else {
leptos::either::Either::Right(view! { "Add" }) leptos::either::Either::Right(view! { "Add" })
}} }}
</button> </Button>
</div> </DialogFooter>
</form> </form>
</DialogContent>
{move || error_msg.0.get().map(|msg| view! { </Dialog>
<div class="text-error text-sm mt-2">{msg}</div>
})}
</div>
<form method="dialog" class="modal-backdrop">
<button on:click=handle_cancel>"close"</button>
</form>
</dialog>
} }
} }

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

View File

@@ -1,2 +1,3 @@
pub mod table; pub mod table;
pub mod add_torrent; pub mod add_torrent;
pub mod detail;

View File

@@ -210,6 +210,8 @@ fn TorrentRow(
let h = hash.clone(); let h = hash.clone();
let torrent = Memo::new(move |_| store.torrents.with(|map| map.get(&h).cloned())); let torrent = Memo::new(move |_| store.torrents.with(|map| map.get(&h).cloned()));
let stored_hash = StoredValue::new(hash.clone());
view! { view! {
<Show when=move || torrent.get().is_some() fallback=|| ()> <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" }; 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! { 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="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-24 px-2 font-mono text-xs text-muted-foreground">{format_bytes(t.size)}</div>
<div class="w-48 px-2"> <div class="w-48 px-2">
@@ -251,6 +264,8 @@ fn TorrentCard(
let h = hash.clone(); let h = hash.clone();
let torrent = Memo::new(move |_| store.torrents.with(|map| map.get(&h).cloned())); let torrent = Memo::new(move |_| store.torrents.with(|map| map.get(&h).cloned()));
let stored_hash = StoredValue::new(hash.clone());
view! { view! {
<Show when=move || torrent.get().is_some() fallback=|| ()> <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" }; 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! { 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"> <Card class="h-full select-none cursor-pointer hover:border-primary transition-colors">
<CardHeader class="p-3 pb-0"> <CardHeader class="p-3 pb-0">
<div class="flex justify-between items-start gap-2"> <div class="flex justify-between items-start gap-2">
@@ -285,6 +312,7 @@ fn TorrentCard(
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div>
} }
} }
} }

View File

@@ -69,6 +69,7 @@ pub struct TorrentStore {
pub global_stats: RwSignal<GlobalStats>, pub global_stats: RwSignal<GlobalStats>,
pub notifications: RwSignal<Vec<NotificationItem>>, pub notifications: RwSignal<Vec<NotificationItem>>,
pub user: RwSignal<Option<String>>, pub user: RwSignal<Option<String>>,
pub selected_torrent: RwSignal<Option<String>>,
} }
pub fn provide_torrent_store() { pub fn provide_torrent_store() {
@@ -78,10 +79,11 @@ pub fn provide_torrent_store() {
let global_stats = RwSignal::new(GlobalStats::default()); let global_stats = RwSignal::new(GlobalStats::default());
let notifications = RwSignal::new(Vec::<NotificationItem>::new()); let notifications = RwSignal::new(Vec::<NotificationItem>::new());
let user = RwSignal::new(Option::<String>::None); 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 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); provide_context(store);
let notifications_for_sse = notifications; let notifications_for_sse = notifications;

Binary file not shown.

Binary file not shown.

Binary file not shown.