Compare commits

..

7 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
spinline
7707bfff15 fix: context menu reaktivite bug'ı düzeltildi, sidebar leptos-shadcn-ui ile güncellendi
All checks were successful
Build MIPS Binary / build (push) Successful in 5m12s
- context_menu.rs: leptos-shadcn-context-menu kütüphanesini bypasslayarak kendi reaktif implementasyonumuz yazıldı (Show ile reaktif render, Portal'sız fixed position)
- sidebar.rs: Button, Avatar, AvatarFallback, Separator bileşenleri ile güncellendi
- Kullanılmayan handle_logout kaldırıldı
2026-02-10 23:26:56 +03:00
spinline
376615813b feat: finalize shadcn integration with portal-based context menu and clean build
All checks were successful
Build MIPS Binary / build (push) Successful in 5m13s
2026-02-10 23:16:13 +03:00
23 changed files with 1016 additions and 600 deletions

240
Cargo.lock generated
View File

@@ -1260,7 +1260,23 @@ dependencies = [
"gloo-timers", "gloo-timers",
"js-sys", "js-sys",
"leptos", "leptos",
"leptos-shadcn-ui", "leptos-shadcn-alert",
"leptos-shadcn-avatar",
"leptos-shadcn-badge",
"leptos-shadcn-button",
"leptos-shadcn-card",
"leptos-shadcn-context-menu",
"leptos-shadcn-dialog",
"leptos-shadcn-dropdown-menu",
"leptos-shadcn-input",
"leptos-shadcn-label",
"leptos-shadcn-progress",
"leptos-shadcn-scroll-area",
"leptos-shadcn-separator",
"leptos-shadcn-sheet",
"leptos-shadcn-tabs",
"leptos-shadcn-toast",
"leptos-shadcn-tooltip",
"leptos-use", "leptos-use",
"leptos_router", "leptos_router",
"log", "log",
@@ -2149,6 +2165,50 @@ 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]]
name = "leptos-shadcn-avatar"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cb3c5b1f5ba02f7282b55fde1513cdfecef3b25bf5fa44e1eb29fcaf8b927c5"
dependencies = [
"leptos",
"leptos-shadcn-signal-management",
"leptos-style",
"tailwind_fuse",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "leptos-shadcn-badge"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24578fb0bc21eb21be4e686e6719c7e183acb8fd071a4f81fb27fe452751c88a"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]] [[package]]
name = "leptos-shadcn-button" name = "leptos-shadcn-button"
version = "0.8.1" version = "0.8.1"
@@ -2164,6 +2224,67 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "leptos-shadcn-card"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b5cda16742d1e20284e5f6805eab88b6e54c1378d1548a8e15a5eedda1ea3eb"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-context-menu"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0f440e9a7517dfe6ba758080ddba1dfe42e4697008f60adfc112c5da02dca8d"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"wasm-bindgen",
"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"
@@ -2180,6 +2301,81 @@ 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]]
name = "leptos-shadcn-progress"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a34ca41b8ebfd7f29126e4f8656987834f3613717016f11f3983da85a90669f6"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5dfda49f059fd4d1549d663e6743e37a5c6c84d1ac2d6daec32caa3156bc268"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-sheet"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba85819a0c94a7705ed92989442c64cc75d9ed3a4540e711e87c56b206431611"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]] [[package]]
name = "leptos-shadcn-signal-management" name = "leptos-shadcn-signal-management"
version = "0.1.0" version = "0.1.0"
@@ -2195,20 +2391,50 @@ dependencies = [
] ]
[[package]] [[package]]
name = "leptos-shadcn-ui" name = "leptos-shadcn-tabs"
version = "0.9.0" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43430605d3d049a4cf68fb7dff4e6f940426ec48131f4662963f62f11baa3e18" 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 = [ dependencies = [
"gloo-timers", "gloo-timers",
"leptos", "leptos",
"leptos-node-ref", "leptos-node-ref",
"leptos-shadcn-button", "leptos-shadcn-signal-management",
"leptos-shadcn-input",
"leptos-struct-component", "leptos-struct-component",
"leptos-style", "leptos-style",
"leptos_router",
"tailwind_fuse", "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]]

View File

@@ -346,10 +346,7 @@ async fn main() {
match diff::diff_torrents(&previous_torrents, &new_torrents) { match diff::diff_torrents(&previous_torrents, &new_torrents) {
diff::DiffResult::FullUpdate => { diff::DiffResult::FullUpdate => {
let _ = event_bus_tx.send(AppEvent::FullList { let _ = event_bus_tx.send(AppEvent::FullList(new_torrents.clone(), now));
torrents: new_torrents.clone(),
timestamp: now,
});
} }
diff::DiffResult::Partial(updates) => { diff::DiffResult::Partial(updates) => {
for update in updates { for update in updates {

View File

@@ -210,10 +210,7 @@ pub async fn sse_handler(
.unwrap() .unwrap()
.as_secs(); .as_secs();
let event_data = AppEvent::FullList { let event_data = AppEvent::FullList(initial_torrents, timestamp);
torrents: initial_torrents,
timestamp,
};
match rmp_serde::to_vec(&event_data) { match rmp_serde::to_vec(&event_data) {
Ok(bytes) => Event::default().data(BASE64.encode(bytes)), Ok(bytes) => Event::default().data(BASE64.encode(bytes)),
@@ -250,7 +247,7 @@ pub async fn sse_handler(
.keep_alive(axum::response::sse::KeepAlive::default()); .keep_alive(axum::response::sse::KeepAlive::default());
( (
[("content-type", "application/x-msgpack")], [("content-type", "text/event-stream")],
sse sse
) )
} }

View File

@@ -33,4 +33,22 @@ codee = "0.3"
thiserror = "2.0" thiserror = "2.0"
rmp-serde = "1.3" rmp-serde = "1.3"
struct-patch = "0.5" struct-patch = "0.5"
leptos-shadcn-ui = { version = "0.9.0", default-features = false, features = ["button", "input"] }
# ShadCN UI Components (Individual)
leptos-shadcn-button = "0.8"
leptos-shadcn-input = "0.8"
leptos-shadcn-card = "0.8"
leptos-shadcn-badge = "0.8"
leptos-shadcn-context-menu = "0.8"
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"
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

@@ -126,6 +126,14 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
/* Ensure Shadcn Utilities are always available */
.bg-popover { background-color: hsl(var(--popover)); }
.text-popover-foreground { color: hsl(var(--popover-foreground)); }
.border-border { border-color: hsl(var(--border)); }
.shadow-md { box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); }
.z-50 { z-index: 50; }
.z-100 { z-index: 100; }
} }
/* Fix for iOS click/blur events */ /* Fix for iOS click/blur events */

View File

@@ -50,18 +50,17 @@
--text-lg--line-height: calc(1.75 / 1.125); --text-lg--line-height: calc(1.75 / 1.125);
--text-2xl: 1.5rem; --text-2xl: 1.5rem;
--text-2xl--line-height: calc(2 / 1.5); --text-2xl--line-height: calc(2 / 1.5);
--font-weight-normal: 400;
--font-weight-medium: 500; --font-weight-medium: 500;
--font-weight-semibold: 600; --font-weight-semibold: 600;
--font-weight-bold: 700; --font-weight-bold: 700;
--tracking-tight: -0.025em; --tracking-tight: -0.025em;
--tracking-wider: 0.05em;
--leading-tight: 1.25; --leading-tight: 1.25;
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: 0.75rem; --radius-xl: 0.75rem;
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--animate-spin: spin 1s linear infinite; --animate-spin: spin 1s linear infinite;
--blur-sm: 8px;
--default-transition-duration: 150ms; --default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans); --default-font-family: var(--font-sans);
@@ -239,9 +238,6 @@
.pointer-events-auto { .pointer-events-auto {
pointer-events: auto; pointer-events: auto;
} }
.pointer-events-none {
pointer-events: none;
}
.absolute { .absolute {
position: absolute; position: absolute;
} }
@@ -254,12 +250,15 @@
.static { .static {
position: static; position: static;
} }
.inset-0 {
inset: calc(var(--spacing) * 0);
}
.inset-y-0 {
inset-block: calc(var(--spacing) * 0);
}
.top-1\/2 { .top-1\/2 {
top: calc(1/2 * 100%); top: calc(1/2 * 100%);
} }
.top-full {
top: 100%;
}
.right-0 { .right-0 {
right: calc(var(--spacing) * 0); right: calc(var(--spacing) * 0);
} }
@@ -278,8 +277,11 @@
.left-2 { .left-2 {
left: calc(var(--spacing) * 2); left: calc(var(--spacing) * 2);
} }
.z-10 { .z-40 {
z-index: 10; z-index: 40;
}
.z-50 {
z-index: 50;
} }
.z-\[99\] { .z-\[99\] {
z-index: 99; z-index: 99;
@@ -305,12 +307,6 @@
max-width: 96rem; max-width: 96rem;
} }
} }
.my-0\.5 {
margin-block: calc(var(--spacing) * 0.5);
}
.mt-1 {
margin-top: calc(var(--spacing) * 1);
}
.mt-2 { .mt-2 {
margin-top: calc(var(--spacing) * 2); margin-top: calc(var(--spacing) * 2);
} }
@@ -413,9 +409,6 @@
.min-h-14 { .min-h-14 {
min-height: calc(var(--spacing) * 14); min-height: calc(var(--spacing) * 14);
} }
.min-h-\[100dvh\] {
min-height: 100dvh;
}
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
@@ -452,6 +445,9 @@
.w-48 { .w-48 {
width: calc(var(--spacing) * 48); width: calc(var(--spacing) * 48);
} }
.w-56 {
width: calc(var(--spacing) * 56);
}
.w-64 { .w-64 {
width: calc(var(--spacing) * 64); width: calc(var(--spacing) * 64);
} }
@@ -470,18 +466,20 @@
.min-w-\[8rem\] { .min-w-\[8rem\] {
min-width: 8rem; min-width: 8rem;
} }
.min-w-\[10rem\] {
min-width: 10rem;
}
.min-w-\[200px\] {
min-width: 200px;
}
.flex-1 { .flex-1 {
flex: 1; flex: 1;
} }
.shrink-0 { .shrink-0 {
flex-shrink: 0; flex-shrink: 0;
} }
.-translate-x-full {
--tw-translate-x: -100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.translate-x-0 {
--tw-translate-x: calc(var(--spacing) * 0);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1\/2 { .-translate-y-1\/2 {
--tw-translate-y: calc(calc(1/2 * 100%) * -1); --tw-translate-y: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -528,9 +526,6 @@
.justify-start { .justify-start {
justify-content: flex-start; justify-content: flex-start;
} }
.gap-0\.5 {
gap: calc(var(--spacing) * 0.5);
}
.gap-1 { .gap-1 {
gap: calc(var(--spacing) * 1); gap: calc(var(--spacing) * 1);
} }
@@ -592,9 +587,6 @@
.rounded-full { .rounded-full {
border-radius: calc(infinity * 1px); border-radius: calc(infinity * 1px);
} }
.rounded-lg {
border-radius: var(--radius-lg);
}
.rounded-md { .rounded-md {
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
@@ -682,10 +674,10 @@
.bg-background { .bg-background {
background-color: var(--color-background); background-color: var(--color-background);
} }
.bg-background\/95 { .bg-background\/80 {
background-color: color-mix(in srgb, hsl(var(--background)) 95%, transparent); background-color: color-mix(in srgb, hsl(var(--background)) 80%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-background) 95%, transparent); background-color: color-mix(in oklab, var(--color-background) 80%, transparent);
} }
} }
.bg-blue-100 { .bg-blue-100 {
@@ -802,15 +794,15 @@
.pr-2 { .pr-2 {
padding-right: calc(var(--spacing) * 2); padding-right: calc(var(--spacing) * 2);
} }
.pb-0 {
padding-bottom: calc(var(--spacing) * 0);
}
.pb-2 { .pb-2 {
padding-bottom: calc(var(--spacing) * 2); padding-bottom: calc(var(--spacing) * 2);
} }
.pb-3 { .pb-3 {
padding-bottom: calc(var(--spacing) * 3); padding-bottom: calc(var(--spacing) * 3);
} }
.pb-8 {
padding-bottom: calc(var(--spacing) * 8);
}
.pl-8 { .pl-8 {
padding-left: calc(var(--spacing) * 8); padding-left: calc(var(--spacing) * 8);
} }
@@ -858,10 +850,6 @@
--tw-font-weight: var(--font-weight-medium); --tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
.font-normal {
--tw-font-weight: var(--font-weight-normal);
font-weight: var(--font-weight-normal);
}
.font-semibold { .font-semibold {
--tw-font-weight: var(--font-weight-semibold); --tw-font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
@@ -870,10 +858,6 @@
--tw-tracking: var(--tracking-tight); --tw-tracking: var(--tracking-tight);
letter-spacing: var(--tracking-tight); letter-spacing: var(--tracking-tight);
} }
.tracking-wider {
--tw-tracking: var(--tracking-wider);
letter-spacing: var(--tracking-wider);
}
.whitespace-nowrap { .whitespace-nowrap {
white-space: nowrap; white-space: nowrap;
} }
@@ -977,25 +961,14 @@
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
} }
.shadow-xl {
--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.ring-2 {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.ring-primary {
--tw-ring-color: var(--color-primary);
}
.ring-offset-background { .ring-offset-background {
--tw-ring-offset-color: var(--color-background); --tw-ring-offset-color: var(--color-background);
} }
.filter { .filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
} }
.backdrop-blur { .backdrop-blur-sm {
--tw-backdrop-blur: blur(8px); --tw-backdrop-blur: blur(var(--blur-sm));
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
} }
@@ -1014,14 +987,23 @@
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration)); transition-duration: var(--tw-duration, var(--default-transition-duration));
} }
.duration-100 { .transition-transform {
--tw-duration: 100ms; transition-property: transform, translate, scale, rotate;
transition-duration: 100ms; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.duration-300 {
--tw-duration: 300ms;
transition-duration: 300ms;
} }
.duration-500 { .duration-500 {
--tw-duration: 500ms; --tw-duration: 500ms;
transition-duration: 500ms; transition-duration: 500ms;
} }
.ease-in-out {
--tw-ease: var(--ease-in-out);
transition-timing-function: var(--ease-in-out);
}
.outline-none { .outline-none {
--tw-outline-style: none; --tw-outline-style: none;
outline-style: none; outline-style: none;
@@ -1030,9 +1012,6 @@
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
} }
.ring-inset {
--tw-ring-inset: inset;
}
.group-open\:block { .group-open\:block {
&:is(:where(.group):is([open], :popover-open, :open) *) { &:is(:where(.group):is([open], :popover-open, :open) *) {
display: block; display: block;
@@ -1083,6 +1062,13 @@
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
} }
} }
.hover\:border-primary {
&:hover {
@media (hover: hover) {
border-color: var(--color-primary);
}
}
}
.hover\:bg-accent { .hover\:bg-accent {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1107,13 +1093,6 @@
} }
} }
} }
.hover\:bg-primary {
&:hover {
@media (hover: hover) {
background-color: var(--color-primary);
}
}
}
.hover\:bg-primary\/90 { .hover\:bg-primary\/90 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1160,11 +1139,21 @@
background-color: var(--color-accent); background-color: var(--color-accent);
} }
} }
.focus\:bg-destructive {
&:focus {
background-color: var(--color-destructive);
}
}
.focus\:text-accent-foreground { .focus\:text-accent-foreground {
&:focus { &:focus {
color: var(--color-accent-foreground); color: var(--color-accent-foreground);
} }
} }
.focus\:text-destructive-foreground {
&:focus {
color: var(--color-destructive-foreground);
}
}
.focus\:ring-2 { .focus\:ring-2 {
&:focus { &:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
@@ -1297,11 +1286,22 @@
max-width: 420px; max-width: 420px;
} }
} }
.lg\:relative {
@media (width >= 64rem) {
position: relative;
}
}
.lg\:hidden { .lg\:hidden {
@media (width >= 64rem) { @media (width >= 64rem) {
display: none; display: none;
} }
} }
.lg\:translate-x-0 {
@media (width >= 64rem) {
--tw-translate-x: calc(var(--spacing) * 0);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
}
.dark\:border-blue-800 { .dark\:border-blue-800 {
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
border-color: var(--color-blue-800); border-color: var(--color-blue-800);
@@ -1479,6 +1479,24 @@
background-color: var(--color-background); background-color: var(--color-background);
color: var(--color-foreground); color: var(--color-foreground);
} }
.bg-popover {
background-color: hsl(var(--popover));
}
.text-popover-foreground {
color: hsl(var(--popover-foreground));
}
.border-border {
border-color: hsl(var(--border));
}
.shadow-md {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.z-50 {
z-index: 50;
}
.z-100 {
z-index: 100;
}
} }
@media (hover: none) { @media (hover: none) {
body { body {
@@ -1712,6 +1730,10 @@
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
} }
@property --tw-ease {
syntax: "*";
inherits: false;
}
@keyframes spin { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@@ -1771,6 +1793,7 @@
--tw-backdrop-saturate: initial; --tw-backdrop-saturate: initial;
--tw-backdrop-sepia: initial; --tw-backdrop-sepia: initial;
--tw-duration: initial; --tw-duration: initial;
--tw-ease: initial;
} }
} }
} }

View File

@@ -1,9 +1,9 @@
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 crate::api;
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use leptos_router::components::{Router, Routes, Route}; use leptos_router::components::{Router, Routes, Route};
@@ -122,14 +122,19 @@ pub fn App() -> impl IntoView {
<p class="text-sm text-muted-foreground">"Yükleniyor..."</p> <p class="text-sm text-muted-foreground">"Yükleniyor..."</p>
</div> </div>
</div> </div>
}> }.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>
} }.into_any()
}/> }/>
<Route path=leptos_router::path!("/settings") view=move || { <Route path=leptos_router::path!("/settings") view=move || {

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 {
@@ -16,17 +21,13 @@ pub fn Login() -> impl IntoView {
let user = username.0.get(); let user = username.0.get();
let pass = password.0.get(); let pass = password.0.get();
log::info!("Attempting login for user: {}", user);
spawn_local(async move { spawn_local(async move {
match shared::server_fns::auth::login(user, pass).await { match shared::server_fns::auth::login(user, pass).await {
Ok(_) => { Ok(_) => {
log::info!("Login successful, redirecting...");
let window = web_sys::window().expect("window should exist"); let window = web_sys::window().expect("window should exist");
let _ = window.location().set_href("/"); let _ = window.location().set_href("/");
} }
Err(e) => { Err(_) => {
log::error!("Login failed: {:?}", e);
error.1.set(Some("Geçersiz kullanıcı adı veya şifre".to_string())); error.1.set(Some("Geçersiz kullanıcı adı veya şifre".to_string()));
loading.1.set(false); loading.1.set(false);
} }
@@ -35,9 +36,9 @@ pub fn Login() -> impl IntoView {
}; };
view! { view! {
<div class="flex items-center justify-center min-h-screen bg-muted/40"> <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" />
@@ -46,60 +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 peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> <Label>"Kullanıcı Adı"</Label>
"Kullanıcı Adı" <Input
</label> input_type="text"
<input placeholder="Kullanıcı adınız"
type="text" value=MaybeProp::derive(move || Some(username.0.get()))
placeholder="Kullanıcı adınız" 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 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" 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 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" 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>
<Show when=move || error.0.get().is_some() fallback=|| ()> <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 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> {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,97 +1,147 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::html; use web_sys::MouseEvent;
use leptos_use::on_click_outside; use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
fn handle_action( // ── Kendi reaktif Context Menu implementasyonumuz ──
hash: String, // leptos-shadcn-context-menu v0.8.1'de ContextMenuContent'te
action: &str, // `if open.get()` statik kontrolü reaktif değil. Aşağıda
on_action: Callback<(String, String)>, // `Show` bileşeni ile düzgün reaktif versiyon yer alıyor.
on_close: Callback<()>,
) {
log::info!("ContextMenu: Action '{}' for hash '{}'", action, hash);
on_action.run((action.to_string(), hash));
on_close.run(());
}
#[component] #[component]
pub fn ContextMenu( pub fn TorrentContextMenu(
position: (i32, i32), children: Children,
torrent_hash: String, torrent_hash: String,
on_close: Callback<()>,
on_action: Callback<(String, String)>, on_action: Callback<(String, String)>,
) -> impl IntoView { ) -> impl IntoView {
let container_ref = NodeRef::<html::Div>::new(); let hash = StoredValue::new(torrent_hash);
let on_action = StoredValue::new(on_action);
let _ = on_click_outside(container_ref, move |_| on_close.run(()));
let (x, y) = position; let open = RwSignal::new(false);
let position = RwSignal::new((0i32, 0i32));
let hash1 = torrent_hash.clone();
let hash2 = torrent_hash.clone(); // Sağ tıklama handler
let hash3 = torrent_hash.clone(); let on_contextmenu = move |e: MouseEvent| {
let hash4 = torrent_hash.clone(); e.prevent_default();
let hash5 = torrent_hash; e.stop_propagation();
position.set((e.client_x(), e.client_y()));
open.set(true);
};
// Menü dışına tıklandığında kapanma
Effect::new(move |_| {
if open.get() {
let cb = Closure::wrap(Box::new(move |_: MouseEvent| {
open.set(false);
}) as Box<dyn Fn(MouseEvent)>);
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let _ = document.add_event_listener_with_callback(
"click",
cb.as_ref().unchecked_ref(),
);
// Cleanup: tek sefer dinleyici — click yakalandığında otomatik kapanıp listener kalıyor
// ama open=false olduğunda effect tekrar çalışmaz, böylece sorun yok.
cb.forget();
}
});
let menu_action = move |action: &'static str| {
open.set(false);
on_action.get_value().run((action.to_string(), hash.get_value()));
};
view! { view! {
<div <div
node_ref=container_ref class="w-full"
class="fixed z-[100] min-w-[200px] animate-in fade-in zoom-in-95 duration-100" on:contextmenu=on_contextmenu
style=format!("left: {}px; top: {}px;", x, y)
on:contextmenu=move |e| e.prevent_default()
> >
<ul class="menu bg-base-200 shadow-xl rounded-box border border-base-300 p-1 gap-0.5"> {children()}
<li>
<button class="flex items-center gap-3 px-3 py-2 hover:bg-primary hover:text-primary-content rounded-lg transition-colors" on:click=move |_| {
handle_action(hash1.clone(), "start", on_action.clone(), on_close.clone());
}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
<span>"Start"</span>
</button>
</li>
<li>
<button class="flex items-center gap-3 px-3 py-2 hover:bg-primary hover:text-primary-content rounded-lg transition-colors" on:click=move |_| {
handle_action(hash2.clone(), "stop", on_action.clone(), on_close.clone());
}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
<span>"Stop"</span>
</button>
</li>
<li>
<button class="flex items-center gap-3 px-3 py-2 hover:bg-primary hover:text-primary-content rounded-lg transition-colors" on:click=move |_| {
handle_action(hash3.clone(), "recheck", on_action.clone(), on_close.clone());
}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span>"Recheck"</span>
</button>
</li>
<div class="divider my-0.5 opacity-50"></div>
<li>
<button class="flex items-center gap-3 px-3 py-2 text-error hover:bg-error hover:text-error-content rounded-lg transition-colors" on:click=move |_| {
handle_action(hash4.clone(), "delete", on_action.clone(), on_close.clone());
}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.164h-2.34c-1.18 0-2.09.984-2.09 2.164v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
<span>"Remove"</span>
</button>
</li>
<li>
<button class="flex items-center gap-3 px-3 py-2 text-error hover:bg-error hover:text-error-content rounded-lg transition-colors" on:click=move |_| {
handle_action(hash5.clone(), "delete_with_data", on_action.clone(), on_close.clone());
}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
<span>"Remove Data"</span>
</button>
</li>
</ul>
</div> </div>
<Show when=move || open.get()>
{
let (x, y) = position.get();
view! {
<div
class="fixed inset-0 z-[99]"
on:click=move |e: MouseEvent| {
e.stop_propagation();
open.set(false);
}
on:contextmenu=move |e: MouseEvent| {
e.prevent_default();
e.stop_propagation();
open.set(false);
}
/>
<div
class="fixed z-[100] min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
style=format!("left: {}px; top: {}px;", x, y)
on:click=move |e: MouseEvent| e.stop_propagation()
>
// Start
<div
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
on:click=move |_| menu_action("start")
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
"Start"
</div>
// Stop
<div
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
on:click=move |_| menu_action("stop")
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
"Stop"
</div>
// Recheck
<div
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
on:click=move |_| menu_action("recheck")
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
"Recheck"
</div>
// Separator
<div class="-mx-1 my-1 h-px bg-border" />
// Remove
<div
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors text-destructive hover:bg-destructive hover:text-destructive-foreground"
on:click=move |_| menu_action("delete")
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.164h-2.34c-1.18 0-2.09.984-2.09 2.164v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
"Remove"
</div>
// Remove with Data
<div
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors text-destructive hover:bg-destructive hover:text-destructive-foreground"
on:click=move |_| menu_action("delete_with_data")
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
"Remove with Data"
</div>
</div>
}
}
</Show>
} }
} }

View File

@@ -5,28 +5,48 @@ use crate::components::layout::statusbar::StatusBar;
#[component] #[component]
pub fn Protected(children: Children) -> impl IntoView { pub fn Protected(children: Children) -> impl IntoView {
// Mobil menü durumu için bir sinyal oluşturuyoruz (RwSignal for easier passing)
let is_mobile_menu_open = RwSignal::new(false);
// Sinyali context olarak sağlıyoruz ki Toolbar ve Sidebar buna erişebilsin
provide_context(is_mobile_menu_open);
view! { view! {
<div class="drawer lg:drawer-open h-full w-full"> <div class="flex h-screen w-full overflow-hidden bg-background">
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col h-full overflow-hidden bg-base-100"> // --- SIDEBAR (Desktop: Sabit, Mobil: Overlay) ---
<aside class=move || {
let base = "fixed inset-y-0 left-0 z-50 w-64 transform transition-transform duration-300 ease-in-out border-r border-border bg-card lg:relative lg:translate-x-0";
if is_mobile_menu_open.get() {
format!("{} translate-x-0", base)
} else {
format!("{} -translate-x-full", base)
}
}>
<Sidebar />
</aside>
// Mobil arka plan karartma (Overlay)
<Show when=move || is_mobile_menu_open.get()>
<div
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm lg:hidden"
on:click=move |_| is_mobile_menu_open.set(false)
></div>
</Show>
// --- MAIN CONTENT AREA ---
<div class="flex flex-1 flex-col overflow-hidden">
// --- TOOLBAR (TOP) --- // --- TOOLBAR (TOP) ---
<Toolbar /> <Toolbar />
// --- MAIN CONTENT --- // --- MAIN CONTENT ---
<main class="flex-1 overflow-hidden relative"> <main class="flex-1 overflow-hidden relative bg-background">
{children()} {children()}
</main> </main>
// --- STATUS BAR (BOTTOM) --- // --- STATUS BAR (BOTTOM) ---
<StatusBar /> <StatusBar />
</div> </div>
// --- SIDEBAR (DRAWER) ---
<div class="drawer-side z-[100]">
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<Sidebar />
</div>
</div> </div>
} }
} }

View File

@@ -1,11 +1,13 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::wasm_bindgen::JsCast;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use crate::api; use leptos_shadcn_button::{Button, ButtonVariant, ButtonSize};
use leptos_shadcn_avatar::{Avatar, AvatarFallback};
use leptos_shadcn_separator::Separator;
#[component] #[component]
pub fn Sidebar() -> impl IntoView { pub fn Sidebar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
let total_count = move || store.torrents.with(|map| map.len()); let total_count = move || store.torrents.with(|map| map.len());
let downloading_count = move || { let downloading_count = move || {
@@ -50,35 +52,12 @@ pub fn Sidebar() -> impl IntoView {
}) })
}; };
let close_drawer = move || {
// With Shadcn Sheet, this logic might change, but for now we keep DOM manipulation minimal or handled by parent
if let Some(element) = document().get_element_by_id("mobile-sheet-trigger") {
// Logic to close sheet if open (simulated click or state change)
}
};
let set_filter = move |f: crate::store::FilterStatus| { let set_filter = move |f: crate::store::FilterStatus| {
store.filter.set(f); store.filter.set(f);
close_drawer(); is_mobile_menu_open.set(false);
}; };
let filter_class = move |f: crate::store::FilterStatus| { let is_active = move |f: crate::store::FilterStatus| store.filter.get() == f;
let base = "w-full justify-start gap-2 h-9 px-4 py-2 inline-flex items-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";
if store.filter.get() == f {
format!("{} bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", base)
} else {
format!("{} hover:bg-accent hover:text-accent-foreground text-muted-foreground", base)
}
};
let handle_logout = move |_| {
spawn_local(async move {
if api::auth::logout().await.is_ok() {
let window = web_sys::window().expect("window should exist");
let _ = window.location().set_href("/login");
}
});
};
let username = move || { let username = move || {
store.user.get().unwrap_or_else(|| "User".to_string()) store.user.get().unwrap_or_else(|| "User".to_string())
@@ -89,7 +68,7 @@ pub fn Sidebar() -> impl IntoView {
}; };
view! { view! {
<div class="w-64 min-h-[100dvh] flex flex-col bg-card border-r border-border pb-8" style="padding-top: env(safe-area-inset-top);"> <div class="w-full h-full flex flex-col bg-card" style="padding-top: env(safe-area-inset-top);">
<div class="p-4 flex-1 overflow-y-auto"> <div class="p-4 flex-1 overflow-y-auto">
<div class="mb-4 px-2 text-lg font-semibold tracking-tight text-foreground"> <div class="mb-4 px-2 text-lg font-semibold tracking-tight text-foreground">
"VibeTorrent" "VibeTorrent"
@@ -97,78 +76,118 @@ pub fn Sidebar() -> impl IntoView {
<div class="space-y-1"> <div class="space-y-1">
<h4 class="mb-1 rounded-md px-2 py-1 text-sm font-semibold text-muted-foreground">"Filters"</h4> <h4 class="mb-1 rounded-md px-2 py-1 text-sm font-semibold text-muted-foreground">"Filters"</h4>
<button class={move || filter_class(crate::store::FilterStatus::All)} on:click=move |_| set_filter(crate::store::FilterStatus::All)> <Button
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2"> variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::All) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
size=ButtonSize::Sm
class="w-full justify-start gap-2"
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::All))
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg> </svg>
"All" "All"
<span class="ml-auto text-xs font-mono opacity-70">{total_count}</span> <span class="ml-auto text-xs font-mono opacity-70">{total_count}</span>
</button> </Button>
<button class={move || filter_class(crate::store::FilterStatus::Downloading)} on:click=move |_| set_filter(crate::store::FilterStatus::Downloading)> <Button
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2"> variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Downloading) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
size=ButtonSize::Sm
class="w-full justify-start gap-2"
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Downloading))
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg> </svg>
"Downloading" "Downloading"
<span class="ml-auto text-xs font-mono opacity-70">{downloading_count}</span> <span class="ml-auto text-xs font-mono opacity-70">{downloading_count}</span>
</button> </Button>
<button class={move || filter_class(crate::store::FilterStatus::Seeding)} on:click=move |_| set_filter(crate::store::FilterStatus::Seeding)> <Button
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2"> variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Seeding) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
size=ButtonSize::Sm
class="w-full justify-start gap-2"
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Seeding))
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg> </svg>
"Seeding" "Seeding"
<span class="ml-auto text-xs font-mono opacity-70">{seeding_count}</span> <span class="ml-auto text-xs font-mono opacity-70">{seeding_count}</span>
</button> </Button>
<button class={move || filter_class(crate::store::FilterStatus::Completed)} on:click=move |_| set_filter(crate::store::FilterStatus::Completed)> <Button
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2"> variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Completed) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
size=ButtonSize::Sm
class="w-full justify-start gap-2"
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Completed))
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
"Completed" "Completed"
<span class="ml-auto text-xs font-mono opacity-70">{completed_count}</span> <span class="ml-auto text-xs font-mono opacity-70">{completed_count}</span>
</button> </Button>
<button class={move || filter_class(crate::store::FilterStatus::Paused)} on:click=move |_| set_filter(crate::store::FilterStatus::Paused)> <Button
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2"> variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Paused) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
size=ButtonSize::Sm
class="w-full justify-start gap-2"
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Paused))
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg> </svg>
"Paused" "Paused"
<span class="ml-auto text-xs font-mono opacity-70">{paused_count}</span> <span class="ml-auto text-xs font-mono opacity-70">{paused_count}</span>
</button> </Button>
<button class={move || filter_class(crate::store::FilterStatus::Inactive)} on:click=move |_| set_filter(crate::store::FilterStatus::Inactive)> <Button
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2"> variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Inactive) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
size=ButtonSize::Sm
class="w-full justify-start gap-2"
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Inactive))
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" /> <path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg> </svg>
"Inactive" "Inactive"
<span class="ml-auto text-xs font-mono opacity-70">{inactive_count}</span> <span class="ml-auto text-xs font-mono opacity-70">{inactive_count}</span>
</button> </Button>
</div> </div>
</div> </div>
<div class="p-4 border-t border-border bg-card"> <Separator />
<div class="p-4 bg-card">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-full bg-muted"> <Avatar class="h-8 w-8">
<span class="flex h-full w-full items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium"> <AvatarFallback class="bg-primary text-primary-foreground text-xs font-medium">
{first_letter} {first_letter}
</span> </AvatarFallback>
</div> </Avatar>
<div class="flex-1 overflow-hidden"> <div class="flex-1 overflow-hidden">
<div class="font-medium text-sm truncate text-foreground">{username}</div> <div class="font-medium text-sm truncate text-foreground">{username}</div>
<div class="text-[10px] text-muted-foreground truncate">"Online"</div> <div class="text-[10px] text-muted-foreground truncate">"Online"</div>
</div> </div>
<button <Button
class="inline-flex items-center justify-center whitespace-nowrap 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-8 w-8 text-destructive" variant=ButtonVariant::Ghost
title="Logout" size=ButtonSize::Icon
on:click=handle_logout class="text-destructive h-8 w-8"
on_click=Callback::new(move |()| {
spawn_local(async move {
if shared::server_fns::auth::logout().await.is_ok() {
let window = web_sys::window().expect("window should exist");
let _ = window.location().set_href("/login");
}
});
})
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg> </svg>
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
} }
} }

View File

@@ -1,58 +1,61 @@
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]
pub fn Toolbar() -> impl IntoView { pub fn Toolbar() -> impl IntoView {
let show_add_modal = signal(false); let show_add_modal = signal(false);
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
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
// Mobile Menu Trigger (Sheet Trigger in full impl) <div class="flex items-center gap-3">
<button id="mobile-sheet-trigger" 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"> // Mobile Menu Trigger
<Button
variant=ButtonVariant::Ghost
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

@@ -1,10 +1,10 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::html;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use leptos_use::{use_timeout_fn, UseTimeoutFnReturn};
use crate::store::{get_action_messages, show_toast_with_signal}; use crate::store::{get_action_messages, show_toast_with_signal};
use crate::api; use crate::api;
use shared::NotificationLevel; use shared::NotificationLevel;
use crate::components::context_menu::TorrentContextMenu;
use leptos_shadcn_card::{Card, CardHeader, CardTitle, CardContent};
fn format_bytes(bytes: i64) -> String { fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"]; const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
@@ -50,7 +50,7 @@ pub fn TorrentTable() -> impl IntoView {
let sort_col = signal(SortColumn::AddedDate); let sort_col = signal(SortColumn::AddedDate);
let sort_dir = signal(SortDirection::Descending); let sort_dir = signal(SortDirection::Descending);
let filtered_hashes = move || { let filtered_hashes = Memo::new(move |_| {
let torrents_map = store.torrents.get(); let torrents_map = store.torrents.get();
let filter = store.filter.get(); let filter = store.filter.get();
let search = store.search_query.get(); let search = store.search_query.get();
@@ -90,7 +90,7 @@ pub fn TorrentTable() -> impl IntoView {
if dir == SortDirection::Descending { cmp.reverse() } else { cmp } if dir == SortDirection::Descending { cmp.reverse() } else { cmp }
}); });
torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>() torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
}; });
let handle_sort = move |col: SortColumn| { let handle_sort = move |col: SortColumn| {
if sort_col.0.get() == col { if sort_col.0.get() == col {
@@ -103,8 +103,6 @@ pub fn TorrentTable() -> impl IntoView {
} }
}; };
let sort_details_ref = NodeRef::<html::Details>::new();
let sort_arrow = move |col: SortColumn| { let sort_arrow = move |col: SortColumn| {
if sort_col.0.get() == col { if sort_col.0.get() == col {
match sort_dir.0.get() { match sort_dir.0.get() {
@@ -114,18 +112,7 @@ pub fn TorrentTable() -> impl IntoView {
} else { view! { <span class="ml-1 text-xs opacity-0 group-hover:opacity-50">""</span> }.into_any() } } else { view! { <span class="ml-1 text-xs opacity-0 group-hover:opacity-50">""</span> }.into_any() }
}; };
let selected_hash = signal(Option::<String>::None); let on_action = Callback::new(move |(action, hash): (String, String)| {
let menu_visible = signal(false);
let menu_position = signal((0, 0));
let handle_context_menu = move |e: web_sys::MouseEvent, hash: String| {
e.prevent_default();
menu_position.1.set((e.client_x(), e.client_y()));
selected_hash.1.set(Some(hash));
menu_visible.1.set(true);
};
let on_action = move |(action, hash): (String, String)| {
let (success_msg_str, error_msg_str): (&'static str, &'static str) = get_action_messages(&action); let (success_msg_str, error_msg_str): (&'static str, &'static str) = get_action_messages(&action);
let success_msg = success_msg_str.to_string(); let success_msg = success_msg_str.to_string();
let error_msg = error_msg_str.to_string(); let error_msg = error_msg_str.to_string();
@@ -143,10 +130,10 @@ pub fn TorrentTable() -> impl IntoView {
Err(e) => show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: {:?}", error_msg, e)), Err(e) => show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: {:?}", error_msg, e)),
} }
}); });
}; });
view! { view! {
<div class="h-full bg-background relative flex flex-col"> <div class="h-full bg-background relative flex flex-col overflow-hidden">
// --- DESKTOP VIEW --- // --- DESKTOP VIEW ---
<div class="hidden md:flex flex-col h-full overflow-hidden"> <div class="hidden md:flex flex-col h-full overflow-hidden">
// Header // Header
@@ -177,18 +164,16 @@ pub fn TorrentTable() -> impl IntoView {
</div> </div>
</div> </div>
// Regular List (Standard For loop) // Regular List
<div class="flex-1 overflow-y-auto min-h-0"> <div class="flex-1 overflow-y-auto min-h-0">
<For each=filtered_hashes key=|hash| hash.clone() children={ <For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
let handle_context_menu = handle_context_menu.clone(); let on_action = on_action.clone();
move |hash| { move |hash| {
let h = hash.clone();
view! { view! {
<TorrentRow <TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
hash=hash.clone() <TorrentRow hash=hash.clone() />
selected_hash=selected_hash.0 </TorrentContextMenu>
set_selected_hash=selected_hash.1
on_context_menu=handle_context_menu.clone()
/>
} }
} }
} /> } />
@@ -197,61 +182,22 @@ pub fn TorrentTable() -> impl IntoView {
// --- MOBILE VIEW --- // --- MOBILE VIEW ---
<div class="md:hidden flex flex-col h-full bg-muted/10 relative overflow-hidden"> <div class="md:hidden flex flex-col h-full bg-muted/10 relative overflow-hidden">
<div class="px-3 py-2 border-b border-border flex justify-between items-center bg-background/95 backdrop-blur z-10 shrink-0">
<span class="text-xs font-bold opacity-50 uppercase tracking-wider text-muted-foreground">"Torrents"</span>
<details class="dropdown dropdown-end" node_ref=sort_details_ref>
<summary class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal list-none [&::-webkit-details-marker]:hidden cursor-pointer hover:bg-accent hover:text-accent-foreground rounded-sm px-2 py-1 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 pointer-events-none"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" /></svg>
<span class="pointer-events-none">"Sort"</span>
</summary>
<div class="dropdown-content z-[100] absolute right-0 top-full mt-1 min-w-[10rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md">
<div class="px-2 py-1.5 text-xs font-semibold text-muted-foreground">"Sort By"</div>
<ul class="w-full">
{
let columns = vec![(SortColumn::Name, "Name"), (SortColumn::Size, "Size"), (SortColumn::Progress, "Progress"), (SortColumn::Status, "Status"), (SortColumn::DownSpeed, "DL Speed"), (SortColumn::UpSpeed, "Up Speed"), (SortColumn::ETA, "ETA"), (SortColumn::AddedDate, "Date")];
columns.into_iter().map(|(col, label)| {
let is_active = move || sort_col.0.get() == col;
view! {
<li>
<button type="button" class=move || if is_active() { "bg-accent text-accent-foreground font-medium flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-xs outline-none" } else { "flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-xs outline-none hover:bg-accent hover:text-accent-foreground" } on:click=move |_| { handle_sort(col); if let Some(el) = sort_details_ref.get() { el.set_open(false); } }>
{label}
<Show when=is_active fallback=|| ()><span class="ml-auto opacity-70 text-[10px]">{move || match sort_dir.0.get() { SortDirection::Ascending => "", SortDirection::Descending => "" }}</span></Show>
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</div>
</details>
</div>
<div class="flex-1 overflow-y-auto p-3 min-h-0"> <div class="flex-1 overflow-y-auto p-3 min-h-0">
<For each=filtered_hashes key=|hash| hash.clone() children={ <For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
let handle_context_menu = handle_context_menu.clone(); let on_action = on_action.clone();
let menu_pos_setter = menu_position.1.clone();
let menu_vis_setter = menu_visible.1.clone();
move |hash| { move |hash| {
let h = hash.clone();
view! { view! {
<div class="pb-3"> <div class="pb-3">
<TorrentCard <TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
hash=hash.clone() <TorrentCard hash=hash.clone() />
selected_hash=selected_hash.0 </TorrentContextMenu>
set_selected_hash=selected_hash.1
set_menu_position=menu_pos_setter
set_menu_visible=menu_vis_setter
on_context_menu=handle_context_menu.clone()
/>
</div> </div>
} }
} }
} /> } />
</div> </div>
</div> </div>
<Show when=move || menu_visible.0.get() fallback=|| ()>
<crate::components::context_menu::ContextMenu position=menu_position.0.get() torrent_hash=selected_hash.0.get().unwrap_or_default() on_close=Callback::new(move |()| menu_visible.1.set(false)) on_action=Callback::new(move |args| on_action(args)) />
</Show>
</div> </div>
} }
} }
@@ -259,44 +205,33 @@ pub fn TorrentTable() -> impl IntoView {
#[component] #[component]
fn TorrentRow( fn TorrentRow(
hash: String, hash: String,
selected_hash: ReadSignal<Option<String>>,
set_selected_hash: WriteSignal<Option<String>>,
on_context_menu: impl Fn(web_sys::MouseEvent, String) + 'static + Clone + Send + Sync,
) -> impl IntoView { ) -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
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=|| ()>
{ {
let on_context_menu = on_context_menu.clone();
let hash = hash.clone();
move || { move || {
let t = torrent.get().unwrap(); let t = torrent.get().unwrap();
let t_hash = hash.clone();
let t_name = t.name.clone(); let t_name = t.name.clone();
let status_color = match t.status { shared::TorrentStatus::Seeding => "text-green-500", shared::TorrentStatus::Downloading => "text-blue-500", shared::TorrentStatus::Paused => "text-yellow-500", shared::TorrentStatus::Error => "text-red-500", _ => "text-muted-foreground" }; let 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 selected_hash_clone = selected_hash.clone();
let t_hash_row = t_hash.clone();
view! { view! {
<div <div
class=move || { class=move || {
let base = "flex items-center text-sm hover:bg-muted/50 border-b border-border h-[48px] px-2 select-none cursor-pointer"; let selected = store.selected_torrent.get();
if selected_hash_clone.get() == Some(t_hash_row.clone()) { format!("{} bg-muted", base) } else { base.to_string() } let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
} if is_selected {
on:contextmenu={ "flex items-center text-sm bg-primary/10 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full"
let t_hash = t_hash.clone(); } else {
let on_context_menu = on_context_menu.clone(); "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"
move |e: web_sys::MouseEvent| on_context_menu(e, t_hash.clone()) }
}
on:click={
let t_hash = t_hash.clone();
let set_selected_hash = set_selected_hash.clone();
move |_| set_selected_hash.set(Some(t_hash.clone()))
} }
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>
@@ -324,70 +259,42 @@ fn TorrentRow(
#[component] #[component]
fn TorrentCard( fn TorrentCard(
hash: String, hash: String,
selected_hash: ReadSignal<Option<String>>,
set_selected_hash: WriteSignal<Option<String>>,
set_menu_position: WriteSignal<(i32, i32)>,
set_menu_visible: WriteSignal<bool>,
on_context_menu: impl Fn(web_sys::MouseEvent, String) + 'static + Clone + Send + Sync,
) -> impl IntoView { ) -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
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=|| ()>
{ {
let hash = hash.clone();
let on_context_menu = on_context_menu.clone();
move || { move || {
let t = torrent.get().unwrap(); let t = torrent.get().unwrap();
let t_hash = hash.clone();
let t_name = t.name.clone(); let t_name = t.name.clone();
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" };
let t_hash_long = t_hash.clone();
let set_menu_position = set_menu_position.clone();
let set_selected_hash = set_selected_hash.clone();
let set_menu_visible = set_menu_visible.clone();
let UseTimeoutFnReturn { start, .. } = use_timeout_fn(
move |pos: (i32, i32)| {
set_menu_position.set(pos);
set_selected_hash.set(Some(t_hash_long.clone()));
set_menu_visible.set(true);
let _ = window().navigator().vibrate_with_duration(50);
},
600.0,
);
let selected_hash_clone = selected_hash.clone();
let t_hash_card = t_hash.clone();
view! { view! {
<div <div
class=move || { class=move || {
let base = "bg-card text-card-foreground rounded-lg border border-border shadow-sm select-none cursor-pointer h-full"; let selected = store.selected_torrent.get();
if selected_hash_clone.get() == Some(t_hash_card.clone()) { format!("{} ring-2 ring-primary ring-inset", base) } else { base.to_string() } let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
} if is_selected {
on:contextmenu={ "ring-2 ring-primary rounded-lg transition-all"
let t_hash = t_hash.clone(); } else {
let on_context_menu = on_context_menu.clone(); "transition-all"
move |e: web_sys::MouseEvent| on_context_menu(e, t_hash.clone()) }
}
on:click={
let t_hash = t_hash.clone();
let set_selected_hash = set_selected_hash.clone();
move |_| set_selected_hash.set(Some(t_hash.clone()))
}
on:touchstart={
let start = start.clone();
move |e: web_sys::TouchEvent| if let Some(touch) = e.touches().get(0) { start((touch.client_x(), touch.client_y())); }
} }
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
> >
<div class="p-3 gap-3 flex flex-col h-full justify-between"> <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"> <div class="flex justify-between items-start gap-2">
<h3 class="font-medium text-sm line-clamp-2 leading-tight">{t_name.clone()}</h3> <CardTitle class="text-sm font-medium leading-tight line-clamp-2">{t_name.clone()}</CardTitle>
<div class={format!("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 {}", status_badge_class)}>{format!("{:?}", t.status)}</div> <div class={format!("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 {}", status_badge_class)}>{format!("{:?}", t.status)}</div>
</div> </div>
</CardHeader>
<CardContent class="p-3 pt-2 gap-3 flex flex-col">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="flex justify-between text-[10px] text-muted-foreground"> <div class="flex justify-between text-[10px] text-muted-foreground">
<span>{format_bytes(t.size)}</span> <span>{format_bytes(t.size)}</span>
@@ -403,11 +310,12 @@ fn TorrentCard(
<div class="flex flex-col"><span>"ETA"</span><span>{format_duration(t.eta)}</span></div> <div class="flex flex-col"><span>"ETA"</span><span>{format_duration(t.eta)}</span></div>
<div class="flex flex-col text-right"><span>"DATE"</span><span>{format_date(t.added_date)}</span></div> <div class="flex flex-col text-right"><span>"DATE"</span><span>{format_date(t.added_date)}</span></div>
</div> </div>
</div> </CardContent>
</Card>
</div> </div>
} }
} }
} }
</Show> </Show>
} }
} }

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;
@@ -124,7 +126,7 @@ pub fn provide_torrent_store() {
match rmp_serde::from_slice::<AppEvent>(&bytes) { match rmp_serde::from_slice::<AppEvent>(&bytes) {
Ok(event) => { Ok(event) => {
match event { match event {
AppEvent::FullList { torrents: list, .. } => { AppEvent::FullList(list, _) => {
log::info!("SSE: Received FullList with {} torrents", list.len()); log::info!("SSE: Received FullList with {} torrents", list.len());
torrents_for_sse.update(|map| { torrents_for_sse.update(|map| {
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect(); let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
@@ -136,13 +138,14 @@ pub fn provide_torrent_store() {
log::debug!("SSE: torrents map now has {} entries", torrents_for_sse.with(|m| m.len())); log::debug!("SSE: torrents map now has {} entries", torrents_for_sse.with(|m| m.len()));
} }
AppEvent::Update(patch) => { AppEvent::Update(patch) => {
torrents_for_sse.update(|map| { let hash_opt = patch.hash.clone();
if let Some(hash) = patch.hash.as_ref() { if let Some(hash) = hash_opt {
if let Some(t) = map.get_mut(hash) { torrents_for_sse.update(|map| {
if let Some(t) = map.get_mut(&hash) {
t.apply(patch); t.apply(patch);
} }
} });
}); }
} }
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); } AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
AppEvent::Notification(n) => { AppEvent::Notification(n) => {

View File

@@ -53,20 +53,10 @@ pub enum TorrentStatus {
} }
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
#[serde(tag = "t", content = "d")]
pub enum AppEvent { pub enum AppEvent {
#[serde(rename = "f")] FullList(Vec<Torrent>, u64),
FullList {
#[serde(rename = "t")]
torrents: Vec<Torrent>,
#[serde(rename = "ts")]
timestamp: u64,
},
#[serde(rename = "u")]
Update(TorrentUpdate), Update(TorrentUpdate),
#[serde(rename = "s")]
Stats(GlobalStats), Stats(GlobalStats),
#[serde(rename = "n")]
Notification(SystemNotification), Notification(SystemNotification),
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.