Compare commits

...

43 Commits

Author SHA1 Message Date
spinline
af13b5af09 fix: resolve syntax error and duplicate code in main.rs router definition
All checks were successful
Build MIPS Binary / build (push) Successful in 5m18s
2026-02-11 19:14:15 +03:00
spinline
c8907e7999 revert: remove Toaster component and add test toast message
Some checks failed
Build MIPS Binary / build (push) Failing after 4m35s
2026-02-11 19:08:57 +03:00
spinline
714e2cb7d5 fix: add missing Toaster component to App to render notifications
Some checks failed
Build MIPS Binary / build (push) Failing after 1m21s
2026-02-11 19:05:12 +03:00
spinline
f35b716f93 chore: cleanup frontend unused imports and variables
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-11 19:03:36 +03:00
spinline
47db9fa0c0 chore: cleanup unused backend code after migration to server functions
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-11 19:02:36 +03:00
spinline
47dc4da6d1 fix: downgrade postcss-preset-env for Node 20.11.1 compatibility
All checks were successful
Build MIPS Binary / build (push) Successful in 5m23s
2026-02-11 18:55:37 +03:00
spinline
c501ed9207 fix: use input/output arguments for MsgPack encoding
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-11 18:50:47 +03:00
spinline
4861faee18 fix: use MsgPack type for encoding (remove quotes)
Some checks failed
Build MIPS Binary / build (push) Failing after 1m12s
2026-02-11 18:47:07 +03:00
spinline
6a4943d692 fix: re-add codec.rs for proper compilation
Some checks failed
Build MIPS Binary / build (push) Failing after 1m13s
2026-02-11 18:44:19 +03:00
spinline
b27caa77f2 fix: restore codec.rs for module export
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-11 18:43:10 +03:00
spinline
cba8c20d9b fix: switch to built-in MsgPack codec and sync leptos versions
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-11 18:42:50 +03:00
spinline
0cdd92dc95 fix: resolve messagepack codec trait bounds and literals
Some checks failed
Build MIPS Binary / build (push) Failing after 1m12s
2026-02-11 18:32:41 +03:00
spinline
b9798ce0e2 fix: resolve messagepack codec compilation errors
Some checks failed
Build MIPS Binary / build (push) Failing after 1m13s
2026-02-11 18:21:36 +03:00
spinline
6a882b75b6 feat: implement MessagePack codec for server functions
Some checks failed
Build MIPS Binary / build (push) Failing after 1m12s
2026-02-11 02:01:02 +03:00
spinline
40c9f66e5c fix: toast notifications context issue by wrapping app in SonnerProvider
All checks were successful
Build MIPS Binary / build (push) Successful in 5m14s
2026-02-11 01:42:58 +03:00
spinline
93e853977a feat: simplify theme toggle and improve sidebar layout
All checks were successful
Build MIPS Binary / build (push) Successful in 5m20s
- frontend/src/components/layout/sidebar.rs: Replaced theme selector with simple dark/light toggle button.
- Cleaned up profile section layout and added safe-area padding.
2026-02-11 01:31:30 +03:00
spinline
e3bc956256 feat: migrate to shadcn toast (sonner)
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
- frontend/src/app.rs: Replaced custom ToastContainer with SonnerProvider
- frontend/src/store.rs: Updated show_toast to use leptos_shadcn_toast::toast API
- frontend/src/components/toast.rs: Deleted custom toast component
- frontend/src/components/torrent/add_torrent.rs: Updated toast usage
- frontend/src/components/torrent/table.rs: Updated toast usage
2026-02-11 01:26:46 +03:00
spinline
5b016aca58 fix: iOS WASM Notification Crash
All checks were successful
Build MIPS Binary / build (push) Successful in 5m21s
- frontend/src/utils/notification.rs: Notification API kullanımı is_notification_supported() ile sarmalandı
- frontend/src/utils/notification.rs: leptos_use::use_web_notification yerine güvenli manuel implementasyon yapıldı
- task.md: iOS WASM hatası giderildi olarak işaretlendi
2026-02-11 01:12:04 +03:00
spinline
5bd3d31dd6 feat: Tema Butonu Sidebar'a Taşındı
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
- sidebar.rs: Tema state yönetimi ve UI butonu eklendi (profil alanının yanına)
- statusbar.rs: Tema ile ilgili eski kodlar ve UI kaldırıldı
- implementation_plan.md: Plan dosyası oluşturuldu
- task.md: Görev tamamlandı olarak işaretlendi
2026-02-11 01:08:18 +03:00
spinline
87ddd3bb93 fix: iOS Dark Mode ve Tema Değişimi Düzeltildi
All checks were successful
Build MIPS Binary / build (push) Successful in 5m17s
- tailwind.config.js: darkMode: 'class' olarak ayarlandı (iOS sistem tercihi yerine .dark class kullanımı için)
- statusbar.rs: Tema değişiminde .dark class ekleme mantığı tüm dark temaları kapsayacak şekilde genişletildi
- index.html: Sayfa yüklenirken .dark class ekleyen inline script güncellendi
- public/tailwind.css: PostCSS ile yeniden derlendi (nesting düzleştirildi + .dark seçiciler eklendi)
2026-02-11 01:00:04 +03:00
spinline
463249982c fix: iOS Safari uyumluluk - CSS nesting düzleştirildi
All checks were successful
Build MIPS Binary / build (push) Successful in 5m16s
- PostCSS tabanlı build'e geçildi (@tailwindcss/postcss + postcss-preset-env)
- CSS native nesting (&) düzleştirilerek eski Safari desteği sağlandı
- iOS 15+ ve Safari 15+ desteği eklendi
2026-02-11 00:54:44 +03:00
spinline
9447a66cc1 feat: loading ekranı shadcn Skeleton ile değiştirildi
All checks were successful
Build MIPS Binary / build (push) Successful in 5m16s
- Yükleniyor... spinner yerine uygulamanın layout'unu simüle eden skeleton UI
- Sidebar, header, tablo satırları ve statusbar skeleton'ları
2026-02-11 00:43:05 +03:00
spinline
45247a020e fix: AddTorrent dialog stili düzeltildi, skeleton crate eklendi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
- Dialog buggy leptos-shadcn-dialog yerine doğrudan shadcn HTML markup ile yeniden yazıldı
- Backdrop overlay, card panel, X close butonu eklendi
- leptos-shadcn-skeleton dependency eklendi
- Tailwind CSS rebuild edildi
2026-02-11 00:40:39 +03:00
spinline
77b77c7775 fix: Tailwind CSS rebuild - shadcn crate class'ları @source ile dahil edildi
All checks were successful
Build MIPS Binary / build (push) Successful in 5m15s
- input.css'e @source directive eklendi (cargo registry leptos-shadcn path'i)
- public/tailwind.css yeniden build edildi (1800 → 2940 satır)
- backdrop-blur, data-[state], focus-visible, peer-disabled vb. class'lar artık mevcut
2026-02-11 00:30:35 +03:00
spinline
8ef3008cb8 fix: context menu viewport sınır kontrolü - alta/sağa taşma düzeltildi
All checks were successful
Build MIPS Binary / build (push) Successful in 5m14s
2026-02-11 00:24:42 +03:00
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
spinline
fddc81365b feat: complete modernization with shadcn, stateless auth, and performance optimizations
All checks were successful
Build MIPS Binary / build (push) Successful in 5m20s
2026-02-10 22:16:36 +03:00
spinline
8815727620 feat: migrate to stateless server functions for auth with jwt and shadcn ui
Some checks failed
Build MIPS Binary / build (push) Failing after 3s
2026-02-10 19:20:36 +03:00
spinline
c85c75659e feat: modernize stack with shadcn, struct_patch and msgpack
Some checks failed
Build MIPS Binary / build (push) Failing after 6s
2026-02-10 19:02:53 +03:00
spinline
4b3e713657 refactor: move DB to shared crate, convert push endpoints to server functions, remove dead REST handlers
All checks were successful
Build MIPS Binary / build (push) Successful in 5m17s
2026-02-10 02:05:04 +03:00
spinline
c2bf6e6fd5 fix: remove unnecessary SSE user auth check - login already guards access
All checks were successful
Build MIPS Binary / build (push) Successful in 5m17s
2026-02-10 01:43:40 +03:00
spinline
94bc7cb91d fix: patch coarsetime with portable-atomic for MIPS AtomicU64 support
All checks were successful
Build MIPS Binary / build (push) Successful in 5m18s
2026-02-10 01:29:06 +03:00
spinline
5e1f4b18c2 refactor: migrate torrent/settings endpoints to Leptos Server Functions and remove third_party/coarsetime
Some checks failed
Build MIPS Binary / build (push) Failing after 4m15s
2026-02-10 00:27:39 +03:00
spinline
3f370389aa fix: SSE auth by capturing store context before async block and redirect to setup when needed 2026-02-10 00:12:08 +03:00
spinline
a4fe8d065c debug: log user value in SSE loop
All checks were successful
Build MIPS Binary / build (push) Successful in 5m21s
2026-02-09 23:55:57 +03:00
spinline
3215b38272 fix: add missing spawn_local import
All checks were successful
Build MIPS Binary / build (push) Successful in 5m20s
2026-02-09 23:48:52 +03:00
spinline
8eb594e804 refactor: move SSE logic to spawn_local with continuous user check
Some checks failed
Build MIPS Binary / build (push) Failing after 1m15s
2026-02-09 22:48:00 +03:00
70 changed files with 8894 additions and 6032 deletions

View File

@@ -26,7 +26,7 @@ jobs:
run: |
cd frontend
npm install
npx @tailwindcss/cli -i input.css -o public/tailwind.css
npx @tailwindcss/cli -i input.css -o public/tailwind.css --minify --content './src/**/*.rs'
# Trunk'ın optimizasyonunu kapalı (0) tutuyoruz çünkü Cargo.toml'daki opt-level='z' zaten o işi yapıyor.
trunk build --release

471
Cargo.lock generated
View File

@@ -320,17 +320,19 @@ dependencies = [
"dotenvy",
"futures",
"governor",
"jsonwebtoken",
"leptos",
"leptos_axum",
"mime_guess",
"openssl",
"quick-xml",
"rand 0.8.5",
"rmp-serde",
"rust-embed",
"serde",
"serde_json",
"shared",
"sqlx",
"struct-patch",
"thiserror 2.0.18",
"time",
"tokio",
@@ -1258,13 +1260,33 @@ dependencies = [
"gloo-timers",
"js-sys",
"leptos",
"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-skeleton",
"leptos-shadcn-tabs",
"leptos-shadcn-toast",
"leptos-shadcn-tooltip",
"leptos-use",
"leptos_router",
"log",
"rmp-serde",
"serde",
"serde-wasm-bindgen",
"serde_json",
"shared",
"struct-patch",
"tailwind_fuse",
"thiserror 2.0.18",
"uuid",
@@ -1398,8 +1420,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -2026,6 +2050,21 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"pem 3.0.6",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "jwt-simple"
version = "0.11.9"
@@ -2117,6 +2156,334 @@ dependencies = [
"web-sys",
]
[[package]]
name = "leptos-node-ref"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f57b1ebc451fe9e7b6c7eba680fa8bc7313b410cc6c0f18481cb55a60ff3ac6"
dependencies = [
"leptos",
"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]]
name = "leptos-shadcn-button"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6d1a7b813b726be7920f7238c127a14129ba4a45fa879312cad3ed2f8a1745"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"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]]
name = "leptos-shadcn-input"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0939cdad5a878d920decda39a4b42ecf4eba15736a92bbd73b1b408807899b8"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"regex",
"tailwind_fuse",
"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]]
name = "leptos-shadcn-signal-management"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5097c5171eb0be12bbf8fd736f4e669012657112865506a825480f2b013f6de"
dependencies = [
"chrono",
"js-sys",
"leptos",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "leptos-shadcn-skeleton"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c14b6bd0f2fe191e3e114a34cee889fc983546ad488e76e76511e3d75ea3f86"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-tabs"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39f817c834e70a8359933b7b274564313be64105370611af96f05508541b661b"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-toast"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3315f7ed844e3286704cc7b57db7209cad592c11eee770f5dc48ebdc92d66cfb"
dependencies = [
"gloo-timers",
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"uuid",
"web-sys",
]
[[package]]
name = "leptos-shadcn-tooltip"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e41d37932d700444e1d3a21f10f198c3c9e76dde3fd78d58da4b5a099939fd7"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-struct-component"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c32085b37b67e61e69e0949d94e36c40e4fde83867681cbb884f9cd40a43881e"
dependencies = [
"leptos",
"leptos-struct-component-macro",
]
[[package]]
name = "leptos-struct-component-macro"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a40efd792acc28a115605b84ecb39e89397a278950bc8f2aad1bdcc7af2033af"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "leptos-style"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c65408961a0bd8e70f317de8973d532a0cb9ffbac910c488d97f9c5a2e4411e2"
dependencies = [
"indexmap",
"leptos",
]
[[package]]
name = "leptos-use"
version = "0.16.3"
@@ -2557,6 +2924,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@@ -3237,6 +3614,39 @@ dependencies = [
"subtle",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]]
name = "rsa"
version = "0.7.2"
@@ -3651,12 +4061,20 @@ dependencies = [
name = "shared"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"bcrypt",
"bytes",
"cookie",
"jsonwebtoken",
"leptos",
"leptos_axum",
"leptos_router",
"quick-xml",
"rmp-serde",
"serde",
"sqlx",
"struct-patch",
"thiserror 2.0.18",
"tokio",
"utoipa",
@@ -3704,6 +4122,18 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simple_asn1"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -3997,6 +4427,26 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "struct-patch"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613c12642d0c0b051bb3faabfbabdb346497963acfe45622b72b4457d4c93a86"
dependencies = [
"struct-patch-derive",
]
[[package]]
name = "struct-patch-derive"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "716442fd9f9a6eb5f847b76cf6d09211f3bdf06f2e30c22e94e38d8ebafdd61a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4107,6 +4557,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca71fb01735fbc6fa13e9390d7a3037dde97053c0b65c0c72c0159cd009d26b"
dependencies = [
"nom",
"tailwind_fuse_macro",
]
[[package]]
name = "tailwind_fuse_macro"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efa51b9ff80b5533001f8452d254a688bc7bb39c6bb77f9e0a19c1664d035888"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
@@ -4640,6 +5103,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"

View File

@@ -17,4 +17,5 @@ strip = true
incremental = false
[patch.crates-io]
coarsetime = { path = "third_party/coarsetime" }
coarsetime = { path = "patches/coarsetime" }

View File

@@ -15,6 +15,8 @@ tower = { version = "0.5", features = ["util", "timeout"] }
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rmp-serde = "1.3"
struct-patch = "0.5"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio-stream = "0.1"
@@ -33,7 +35,6 @@ utoipa-swagger-ui = { version = "9.0", features = ["axum"], optional = true }
web-push = { version = "0.10", default-features = false, features = ["hyper-client"], optional = true }
base64 = "0.22"
openssl = { version = "0.10", features = ["vendored"], optional = true }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
bcrypt = "0.17.0"
axum-extra = { version = "0.10", features = ["cookie"] }
rand = "0.8"
@@ -44,4 +45,5 @@ governor = "0.10.4"
# Leptos
leptos = { version = "0.8.15", features = ["nightly"] }
leptos_axum = { version = "0.8.7" }
leptos_axum = { version = "0.8.7" }
jsonwebtoken = "9"

View File

@@ -9,106 +9,54 @@ pub enum DiffResult {
}
pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
// 1. Structural Check: Eğer torrent sayısı değişmişse (yeni eklenen veya silinen),
// şimdilik basitlik adına FullUpdate gönderiyoruz.
if old.len() != new.len() {
return DiffResult::FullUpdate;
}
// 2. Hash Set Karşılaştırması:
// Sıralama değişmiş olabilir ama torrentler aynı mı?
let old_map: HashMap<&str, &Torrent> = old.iter().map(|t| (t.hash.as_str(), t)).collect();
// Eğer yeni listedeki bir hash eski listede yoksa, yapı değişmiş demektir.
for new_t in new {
if !old_map.contains_key(new_t.hash.as_str()) {
return DiffResult::FullUpdate;
}
}
// 3. Alan Güncellemeleri (Partial Updates)
// Buraya geldiğimizde biliyoruz ki old ve new listelerindeki torrentler (hash olarak) aynı,
// sadece sıraları farklı olabilir veya içindeki veriler güncellenmiş olabilir.
let mut events = Vec::new();
for new_t in new {
// old_map'ten ilgili torrente hash ile ulaşalım (sıradan bağımsız)
let old_t = old_map.get(new_t.hash.as_str()).unwrap();
let mut update = TorrentUpdate {
hash: new_t.hash.clone(),
name: None,
size: None,
down_rate: None,
up_rate: None,
percent_complete: None,
completed: None,
eta: None,
status: None,
error_message: None,
label: None,
};
// Manuel diff creating TorrentUpdate (which is the Patch struct)
let mut patch = TorrentUpdate::default();
let mut has_changes = false;
// Alanları karşılaştır
if old_t.name != new_t.name {
update.name = Some(new_t.name.clone());
has_changes = true;
}
if old_t.size != new_t.size {
update.size = Some(new_t.size);
has_changes = true;
}
if old_t.down_rate != new_t.down_rate {
update.down_rate = Some(new_t.down_rate);
has_changes = true;
}
if old_t.up_rate != new_t.up_rate {
update.up_rate = Some(new_t.up_rate);
has_changes = true;
}
if (old_t.percent_complete - new_t.percent_complete).abs() > 0.01 {
update.percent_complete = Some(new_t.percent_complete);
has_changes = true;
// Torrent tamamlanma kontrolü
if old_t.name != new_t.name { patch.name = Some(new_t.name.clone()); has_changes = true; }
if old_t.size != new_t.size { patch.size = Some(new_t.size); has_changes = true; }
if old_t.down_rate != new_t.down_rate { patch.down_rate = Some(new_t.down_rate); has_changes = true; }
if old_t.up_rate != new_t.up_rate { patch.up_rate = Some(new_t.up_rate); has_changes = true; }
if old_t.completed != new_t.completed { patch.completed = Some(new_t.completed); has_changes = true; }
if old_t.eta != new_t.eta { patch.eta = Some(new_t.eta); has_changes = true; }
if (old_t.percent_complete - new_t.percent_complete).abs() > 0.01 {
patch.percent_complete = Some(new_t.percent_complete);
has_changes = true;
if old_t.percent_complete < 100.0 && new_t.percent_complete >= 100.0 {
tracing::info!("Torrent completed: {} ({})", new_t.name, new_t.hash);
events.push(AppEvent::Notification(SystemNotification {
level: NotificationLevel::Success,
message: format!("Torrent tamamlandı: {}", new_t.name),
}));
}
}
if old_t.completed != new_t.completed {
update.completed = Some(new_t.completed);
has_changes = true;
}
if old_t.eta != new_t.eta {
update.eta = Some(new_t.eta);
has_changes = true;
}
if old_t.status != new_t.status {
update.status = Some(new_t.status.clone());
has_changes = true;
tracing::debug!(
"Torrent status changed: {} ({}) {:?} -> {:?}",
new_t.name, new_t.hash, old_t.status, new_t.status
);
}
if old_t.error_message != new_t.error_message {
update.error_message = Some(new_t.error_message.clone());
has_changes = true;
}
if old_t.label != new_t.label {
update.label = new_t.label.clone();
has_changes = true;
}
if old_t.status != new_t.status { patch.status = Some(new_t.status.clone()); has_changes = true; }
if old_t.error_message != new_t.error_message { patch.error_message = Some(new_t.error_message.clone()); has_changes = true; }
if old_t.label != new_t.label { patch.label = Some(new_t.label.clone()); has_changes = true; }
if has_changes {
events.push(AppEvent::Update(update));
// Set the hash (not an Option in Patch usually, but check shared/src/lib.rs)
// Wait, TorrentUpdate is a Patch, does it have 'hash' field?
// Yes, because Torrent has 'hash' field.
patch.hash = Some(new_t.hash.clone());
events.push(AppEvent::Update(patch));
}
}

View File

@@ -1,154 +1,2 @@
use crate::AppState;
use axum::{
extract::{State, Json},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use time::Duration;
#[derive(Deserialize, ToSchema)]
pub struct LoginRequest {
username: String,
password: String,
#[serde(default)]
remember_me: bool,
}
#[derive(Serialize, ToSchema)]
pub struct UserResponse {
username: String,
}
#[utoipa::path(
post,
path = "/api/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful"),
(status = 401, description = "Invalid credentials"),
(status = 500, description = "Internal server error")
)
)]
pub async fn login_handler(
State(state): State<AppState>,
jar: CookieJar,
Json(payload): Json<LoginRequest>,
) -> impl IntoResponse {
tracing::info!("Login attempt for user: {}", payload.username);
let user = match state.db.get_user_by_username(&payload.username).await {
Ok(Some(u)) => u,
Ok(None) => {
tracing::warn!("Login failed: User not found for {}", payload.username);
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
}
Err(e) => {
tracing::error!("DB error during login for {}: {}", payload.username, e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
}
};
let (user_id, password_hash) = user;
match bcrypt::verify(&payload.password, &password_hash) {
Ok(true) => {
tracing::info!("Password verified for user: {}", payload.username);
// Create session
let token: String = (0..32).map(|_| {
use rand::{distributions::Alphanumeric, Rng};
rand::thread_rng().sample(Alphanumeric) as char
}).collect();
// Expiration: 30 days if remember_me is true, else 1 day
let expires_in = if payload.remember_me {
60 * 60 * 24 * 30
} else {
60 * 60 * 24
};
let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + expires_in;
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
tracing::error!("Failed to create session for {}: {}", payload.username, e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create session").into_response();
}
let mut cookie = Cookie::build(("auth_token", token))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.build();
cookie.set_max_age(Duration::seconds(expires_in));
tracing::info!("Session created and cookie set for user: {}", payload.username);
(StatusCode::OK, jar.add(cookie), Json(UserResponse { username: payload.username })).into_response()
}
Ok(false) => {
tracing::warn!("Login failed: Invalid password for {}", payload.username);
(StatusCode::UNAUTHORIZED, "Invalid credentials").into_response()
}
Err(e) => {
tracing::error!("Bcrypt error for {}: {}", payload.username, e);
(StatusCode::INTERNAL_SERVER_ERROR, "Auth error").into_response()
}
}
}
#[utoipa::path(
post,
path = "/api/auth/logout",
responses(
(status = 200, description = "Logged out")
)
)]
pub async fn logout_handler(
State(state): State<AppState>,
jar: CookieJar,
) -> impl IntoResponse {
if let Some(token) = jar.get("auth_token") {
let _ = state.db.delete_session(token.value()).await;
}
let cookie = Cookie::build(("auth_token", ""))
.path("/")
.http_only(true)
.max_age(Duration::seconds(-1)) // Expire immediately
.build();
(StatusCode::OK, jar.add(cookie), "Logged out").into_response()
}
#[utoipa::path(
get,
path = "/api/auth/check",
responses(
(status = 200, description = "Authenticated", body = UserResponse),
(status = 401, description = "Not authenticated")
)
)]
pub async fn check_auth_handler(
State(state): State<AppState>,
jar: CookieJar,
) -> impl IntoResponse {
if let Some(token) = jar.get("auth_token") {
match state.db.get_session_user(token.value()).await {
Ok(Some(user_id)) => {
// Fetch username
// We need a helper in db.rs to get username by id, or we can use a direct query here if we don't want to change db.rs interface yet.
// But better to add `get_username_by_id` to db.rs
// For now let's query directly or via a new db method.
if let Ok(Some(username)) = state.db.get_username_by_id(user_id).await {
return (StatusCode::OK, Json(UserResponse { username })).into_response();
}
},
_ => {} // Invalid session
}
}
StatusCode::UNAUTHORIZED.into_response()
}
// This file is intentionally empty as authentication is now handled by Server Functions.
// See shared/src/server_fns/auth.rs

View File

@@ -1,13 +1,4 @@
use shared::{
xmlrpc::{self, RpcParam},
AddTorrentRequest, GlobalLimitRequest, SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest,
TorrentFile, TorrentPeer, TorrentTracker,
};
use crate::AppState;
#[cfg(feature = "push-notifications")]
use crate::push;
use axum::{
extract::{Json, Path, State},
http::{header, StatusCode, Uri},
response::IntoResponse,
BoxError,
@@ -37,7 +28,6 @@ pub async fn static_handler(uri: Uri) -> impl IntoResponse {
if path.contains('.') {
return StatusCode::NOT_FOUND.into_response();
}
// Fallback to index.html for SPA routing
match Asset::get("index.html") {
Some(content) => {
let mime = mime_guess::from_path("index.html").first_or_octet_stream();
@@ -49,614 +39,6 @@ pub async fn static_handler(uri: Uri) -> impl IntoResponse {
}
}
// --- TORRENT ACTIONS ---
/// Add a new torrent via magnet link or URL
#[utoipa::path(
post,
path = "/api/torrents/add",
request_body = AddTorrentRequest,
responses(
(status = 200, description = "Torrent added successfully"),
(status = 500, description = "Internal server error or rTorrent fault")
)
)]
pub async fn add_torrent_handler(
State(state): State<AppState>,
Json(payload): Json<AddTorrentRequest>,
) -> StatusCode {
tracing::info!(
"Received add_torrent request. URI length: {}",
payload.uri.len()
);
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![RpcParam::from(""), RpcParam::from(payload.uri.as_str())];
match client.call("load.start", &params).await {
Ok(response) => {
tracing::debug!("rTorrent response to load.start: {}", response);
if response.contains("faultCode") {
tracing::error!("rTorrent returned fault: {}", response);
return StatusCode::INTERNAL_SERVER_ERROR;
}
// Note: Frontend shows its own toast, no SSE notification needed
StatusCode::OK
}
Err(e) => {
tracing::error!("Failed to add torrent: {}", e);
// Note: Frontend shows its own toast, no SSE notification needed
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
/// Perform an action on a torrent (start, stop, delete)
#[utoipa::path(
post,
path = "/api/torrents/action",
request_body = TorrentActionRequest,
responses(
(status = 200, description = "Action executed successfully"),
(status = 400, description = "Invalid action or request"),
(status = 403, description = "Forbidden: Security risk detected"),
(status = 500, description = "Internal server error")
)
)]
pub async fn handle_torrent_action(
State(state): State<AppState>,
Json(payload): Json<TorrentActionRequest>,
) -> impl IntoResponse {
tracing::info!(
"Received action: {} for hash: {}",
payload.action,
payload.hash
);
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
// Special handling for delete_with_data
if payload.action == "delete_with_data" {
return match delete_torrent_with_data(&client, &payload.hash).await {
Ok(msg) => {
// Note: Frontend shows its own toast
(StatusCode::OK, msg).into_response()
}
Err((status, msg)) => (status, msg).into_response(),
};
}
let method = match payload.action.as_str() {
"start" => "d.start",
"stop" => "d.stop",
"delete" => "d.erase",
_ => return (StatusCode::BAD_REQUEST, "Invalid action").into_response(),
};
let params = vec![RpcParam::from(payload.hash.as_str())];
match client.call(method, &params).await {
Ok(_) => {
// Note: Frontend shows its own toast, no SSE notification needed
(StatusCode::OK, "Action executed").into_response()
}
Err(e) => {
tracing::error!("RPC error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to execute action",
)
.into_response()
}
}
}
/// Helper function to handle secure deletion of torrent data
async fn delete_torrent_with_data(
client: &xmlrpc::RtorrentClient,
hash: &str,
) -> Result<&'static str, (StatusCode, String)> {
let params_hash = vec![RpcParam::from(hash)];
// 1. Get Base Path
let path_xml = client
.call("d.base_path", &params_hash)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to call rTorrent: {}", e),
)
})?;
let path = xmlrpc::parse_string_response(&path_xml).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to parse path: {}", e),
)
})?;
// 1.5 Get Default Download Directory (Sandbox Root)
let root_xml = client.call("directory.default", &[]).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get valid download root: {}", e),
)
})?;
let root_path_str = xmlrpc::parse_string_response(&root_xml).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to parse root path: {}", e),
)
})?;
// Resolve Paths (Canonicalize) to prevent .. traversal and symlink attacks
let root_path = tokio::fs::canonicalize(std::path::Path::new(&root_path_str))
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Invalid download root configuration (on server): {}", e),
)
})?;
// Check if target path exists before trying to resolve it
let target_path_raw = std::path::Path::new(&path);
if !tokio::fs::try_exists(target_path_raw)
.await
.unwrap_or(false)
{
tracing::warn!(
"Data path not found: {:?}. Removing torrent only.",
target_path_raw
);
// If file doesn't exist, we just remove the torrent entry
client.call("d.erase", &params_hash).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to erase torrent: {}", e),
)
})?;
return Ok("Torrent removed (Data not found)");
}
let target_path = tokio::fs::canonicalize(target_path_raw)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Invalid data path: {}", e),
)
})?;
tracing::info!(
"Delete request: Target='{:?}', Root='{:?}'",
target_path,
root_path
);
// SECURITY CHECK: Ensure path is inside root_path
if !target_path.starts_with(&root_path) {
tracing::error!(
"Security Risk: Attempted to delete path outside download directory: {:?}",
target_path
);
return Err((
StatusCode::FORBIDDEN,
"Security Error: Cannot delete files outside default download directory".to_string(),
));
}
// SECURITY CHECK: Ensure we are not deleting the root itself
if target_path == root_path {
return Err((
StatusCode::BAD_REQUEST,
"Security Error: Cannot delete the download root directory itself".to_string(),
));
}
// 2. Erase Torrent first
client.call("d.erase", &params_hash).await.map_err(|e| {
tracing::warn!("Failed to erase torrent entry: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to erase torrent: {}", e),
)
})?;
// 3. Delete Files via Native FS
let delete_result = if target_path.is_dir() {
tokio::fs::remove_dir_all(&target_path).await
} else {
tokio::fs::remove_file(&target_path).await
};
match delete_result {
Ok(_) => Ok("Torrent and data deleted"),
Err(e) => {
tracing::error!("Failed to delete data at {:?}: {}", target_path, e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to delete data: {}", e),
))
}
}
}
// --- NEW HANDLERS ---
/// Get rTorrent version
#[utoipa::path(
get,
path = "/api/system/version",
responses(
(status = 200, description = "rTorrent version", body = String),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_version_handler(State(state): State<AppState>) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
match client.call("system.client_version", &[]).await {
Ok(xml) => {
let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml);
(StatusCode::OK, version).into_response()
}
Err(e) => {
tracing::error!("Failed to get version: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to get version").into_response()
}
}
}
/// Get files for a torrent
#[utoipa::path(
get,
path = "/api/torrents/{hash}/files",
responses(
(status = 200, description = "Files list", body = Vec<TorrentFile>),
(status = 500, description = "Internal server error")
),
params(
("hash" = String, Path, description = "Torrent Hash")
)
)]
pub async fn get_files_handler(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("f.path="),
RpcParam::from("f.size_bytes="),
RpcParam::from("f.completed_chunks="),
RpcParam::from("f.priority="),
];
match client.call("f.multicall", &params).await {
Ok(xml) => match xmlrpc::parse_multicall_response(&xml) {
Ok(rows) => {
let files: Vec<TorrentFile> = rows
.into_iter()
.enumerate()
.map(|(idx, row)| TorrentFile {
index: idx as u32,
path: row.get(0).cloned().unwrap_or_default(),
size: row.get(1).and_then(|s| s.parse().ok()).unwrap_or(0),
completed_chunks: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
priority: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
})
.collect();
(StatusCode::OK, Json(files)).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Parse error: {}", e),
)
.into_response(),
},
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Get peers for a torrent
#[utoipa::path(
get,
path = "/api/torrents/{hash}/peers",
responses(
(status = 200, description = "Peers list", body = Vec<TorrentPeer>),
(status = 500, description = "Internal server error")
),
params(
("hash" = String, Path, description = "Torrent Hash")
)
)]
pub async fn get_peers_handler(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("p.address="),
RpcParam::from("p.client_version="),
RpcParam::from("p.down_rate="),
RpcParam::from("p.up_rate="),
RpcParam::from("p.completed_percent="),
];
match client.call("p.multicall", &params).await {
Ok(xml) => match xmlrpc::parse_multicall_response(&xml) {
Ok(rows) => {
let peers: Vec<TorrentPeer> = rows
.into_iter()
.map(|row| TorrentPeer {
ip: row.get(0).cloned().unwrap_or_default(),
client: row.get(1).cloned().unwrap_or_default(),
down_rate: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
up_rate: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
progress: row.get(4).and_then(|s| s.parse().ok()).unwrap_or(0.0),
})
.collect();
(StatusCode::OK, Json(peers)).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Parse error: {}", e),
)
.into_response(),
},
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Get trackers for a torrent
#[utoipa::path(
get,
path = "/api/torrents/{hash}/trackers",
responses(
(status = 200, description = "Trackers list", body = Vec<TorrentTracker>),
(status = 500, description = "Internal server error")
),
params(
("hash" = String, Path, description = "Torrent Hash")
)
)]
pub async fn get_trackers_handler(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("t.url="),
RpcParam::from("t.activity_date_last="),
RpcParam::from("t.message="),
];
match client.call("t.multicall", &params).await {
Ok(xml) => {
match xmlrpc::parse_multicall_response(&xml) {
Ok(rows) => {
let trackers: Vec<TorrentTracker> = rows
.into_iter()
.map(|row| {
TorrentTracker {
url: row.get(0).cloned().unwrap_or_default(),
status: "Unknown".to_string(), // Derive from type/activity?
message: row.get(2).cloned().unwrap_or_default(),
}
})
.collect();
(StatusCode::OK, Json(trackers)).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Parse error: {}", e),
)
.into_response(),
}
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Set file priority
#[utoipa::path(
post,
path = "/api/torrents/files/priority",
request_body = SetFilePriorityRequest,
responses(
(status = 200, description = "Priority updated"),
(status = 500, description = "Internal server error")
)
)]
pub async fn set_file_priority_handler(
State(state): State<AppState>,
Json(payload): Json<SetFilePriorityRequest>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
// f.set_priority takes "hash", index, priority
// Priority: 0 (off), 1 (normal), 2 (high)
// f.set_priority is tricky. Let's send as string first as before, or int if we knew.
// Usually priorities are small integers.
// But since we are updating everything to RpcParam, let's use Int if possible or String.
// The previous implementation used string. Let's stick to string for now or try Int.
// Actually, f.set_priority likely takes an integer.
let target = format!("{}:f{}", payload.hash, payload.file_index);
let params = vec![
RpcParam::from(target.as_str()),
RpcParam::from(payload.priority as i64),
];
match client.call("f.set_priority", &params).await {
Ok(_) => {
let _ = client
.call(
"d.update_priorities",
&[RpcParam::from(payload.hash.as_str())],
)
.await;
(StatusCode::OK, "Priority updated").into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Set torrent label
#[utoipa::path(
post,
path = "/api/torrents/label",
request_body = SetLabelRequest,
responses(
(status = 200, description = "Label updated"),
(status = 500, description = "Internal server error")
)
)]
pub async fn set_label_handler(
State(state): State<AppState>,
Json(payload): Json<SetLabelRequest>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![
RpcParam::from(payload.hash.as_str()),
RpcParam::from(payload.label),
];
match client.call("d.custom1.set", &params).await {
Ok(_) => (StatusCode::OK, "Label updated").into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Get global speed limits
#[utoipa::path(
get,
path = "/api/settings/global-limits",
responses(
(status = 200, description = "Current limits", body = GlobalLimitRequest),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_global_limit_handler(State(state): State<AppState>) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
// throttle.global_down.max_rate, throttle.global_up.max_rate
let down_fut = client.call("throttle.global_down.max_rate", &[]);
let up_fut = client.call("throttle.global_up.max_rate", &[]);
let down = match down_fut.await {
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
Err(_) => -1,
};
let up = match up_fut.await {
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
Err(_) => -1,
};
let resp = GlobalLimitRequest {
max_download_rate: Some(down),
max_upload_rate: Some(up),
};
(StatusCode::OK, Json(resp)).into_response()
}
/// Set global speed limits
#[utoipa::path(
post,
path = "/api/settings/global-limits",
request_body = GlobalLimitRequest,
responses(
(status = 200, description = "Limits updated"),
(status = 500, description = "Internal server error")
)
)]
pub async fn set_global_limit_handler(
State(state): State<AppState>,
Json(payload): Json<GlobalLimitRequest>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
// Use throttle.global_*.max_rate.set_kb which is more reliable than .set (which is buggy)
// The .set_kb method expects KB/s, so we convert bytes to KB
if let Some(down) = payload.max_download_rate {
// Convert bytes/s to KB/s (divide by 1024)
let down_kb = down / 1024;
tracing::info!(
"Setting download limit: {} bytes/s = {} KB/s",
down,
down_kb
);
// Use set_kb with empty string as first param (throttle name), then value
if let Err(e) = client
.call(
"throttle.global_down.max_rate.set_kb",
&[RpcParam::from(""), RpcParam::Int(down_kb)],
)
.await
{
tracing::error!("Failed to set download limit: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to set down limit: {}", e),
)
.into_response();
}
}
if let Some(up) = payload.max_upload_rate {
// Convert bytes/s to KB/s
let up_kb = up / 1024;
tracing::info!("Setting upload limit: {} bytes/s = {} KB/s", up, up_kb);
if let Err(e) = client
.call(
"throttle.global_up.max_rate.set_kb",
&[RpcParam::from(""), RpcParam::Int(up_kb)],
)
.await
{
tracing::error!("Failed to set upload limit: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to set up limit: {}", e),
)
.into_response();
}
}
(StatusCode::OK, "Limits updated").into_response()
}
pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
if err.is::<tower::timeout::error::Elapsed>() {
(StatusCode::REQUEST_TIMEOUT, "Request timed out")
@@ -667,44 +49,3 @@ pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
)
}
}
// --- PUSH NOTIFICATION HANDLERS ---
#[cfg(feature = "push-notifications")]
/// Get VAPID public key for push subscription
#[utoipa::path(
get,
path = "/api/push/public-key",
responses(
(status = 200, description = "VAPID public key", body = String)
)
)]
pub async fn get_push_public_key_handler(
State(state): State<AppState>,
) -> impl IntoResponse {
let public_key = state.push_store.get_public_key();
(StatusCode::OK, Json(serde_json::json!({ "publicKey": public_key }))).into_response()
}
#[cfg(feature = "push-notifications")]
/// Subscribe to push notifications
#[utoipa::path(
post,
path = "/api/push/subscribe",
request_body = push::PushSubscription,
responses(
(status = 200, description = "Subscription saved"),
(status = 400, description = "Invalid subscription data")
)
)]
pub async fn subscribe_push_handler(
State(state): State<AppState>,
Json(subscription): Json<push::PushSubscription>,
) -> impl IntoResponse {
tracing::info!("Received push subscription: {:?}", subscription);
state.push_store.add_subscription(subscription).await;
(StatusCode::OK, "Subscription saved").into_response()
}

View File

@@ -1,125 +1,2 @@
use crate::AppState;
use axum::{
extract::{State, Json},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use time::Duration;
#[derive(Deserialize, ToSchema)]
pub struct SetupRequest {
username: String,
password: String,
}
#[derive(Serialize, ToSchema)]
pub struct SetupStatusResponse {
completed: bool,
}
#[utoipa::path(
get,
path = "/api/setup/status",
responses(
(status = 200, description = "Setup status", body = SetupStatusResponse)
)
)]
pub async fn get_setup_status_handler(State(state): State<AppState>) -> impl IntoResponse {
let completed = match state.db.has_users().await {
Ok(has) => has,
Err(e) => {
tracing::error!("DB error checking users: {}", e);
false
}
};
Json(SetupStatusResponse { completed }).into_response()
}
#[utoipa::path(
post,
path = "/api/setup",
request_body = SetupRequest,
responses(
(status = 200, description = "Setup completed and logged in"),
(status = 400, description = "Invalid request"),
(status = 403, description = "Setup already completed"),
(status = 500, description = "Internal server error")
)
)]
pub async fn setup_handler(
State(state): State<AppState>,
jar: CookieJar,
Json(payload): Json<SetupRequest>,
) -> impl IntoResponse {
// 1. Check if setup is already completed (i.e., users exist)
match state.db.has_users().await {
Ok(true) => return (StatusCode::FORBIDDEN, "Setup already completed").into_response(),
Err(e) => {
tracing::error!("DB error checking users: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
}
Ok(false) => {} // Proceed
}
// 2. Validate input
if payload.username.len() < 3 || payload.password.len() < 6 {
return (StatusCode::BAD_REQUEST, "Username must be at least 3 chars, password at least 6").into_response();
}
// 3. Create User
// Lower cost for faster login on low-power devices (MIPS routers etc.)
let password_hash = match bcrypt::hash(&payload.password, 6) {
Ok(h) => h,
Err(e) => {
tracing::error!("Failed to hash password: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to process password").into_response();
}
};
if let Err(e) = state.db.create_user(&payload.username, &password_hash).await {
tracing::error!("Failed to create user: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create user").into_response();
}
// 4. Auto-Login (Create Session)
// Get the created user's ID
let user = match state.db.get_user_by_username(&payload.username).await {
Ok(Some(u)) => u,
Ok(None) => return (StatusCode::INTERNAL_SERVER_ERROR, "User created but not found").into_response(),
Err(e) => {
tracing::error!("DB error fetching new user: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
}
};
let (user_id, _) = user;
// Create session token
let token: String = (0..32).map(|_| {
use rand::{distributions::Alphanumeric, Rng};
rand::thread_rng().sample(Alphanumeric) as char
}).collect();
// Default expiration: 1 day (since it's not "remember me")
let expires_in = 60 * 60 * 24;
let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + expires_in;
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
tracing::error!("Failed to create session for new user: {}", e);
// Even if session fails, setup is technically complete, but login failed.
// We return OK but user will have to login manually.
return (StatusCode::OK, "Setup completed, please login").into_response();
}
let mut cookie = Cookie::build(("auth_token", token))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.build();
cookie.set_max_age(Duration::seconds(expires_in));
(StatusCode::OK, jar.add(cookie), "Setup completed and logged in").into_response()
}
// This file is intentionally empty as setup is now handled by Server Functions.
// See shared/src/server_fns/auth.rs

View File

@@ -1,4 +1,3 @@
mod db;
mod diff;
mod handlers;
#[cfg(feature = "push-notifications")]
@@ -26,7 +25,6 @@ use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, watch};
use tower::ServiceBuilder;
use tower_governor::GovernorLayer;
use tower_http::{
compression::{CompressionLayer, CompressionLevel},
cors::CorsLayer,
@@ -42,24 +40,23 @@ pub struct AppState {
pub tx: Arc<watch::Sender<Vec<Torrent>>>,
pub event_bus: broadcast::Sender<AppEvent>,
pub scgi_socket_path: String,
pub db: db::Db,
pub db: shared::db::Db,
#[cfg(feature = "push-notifications")]
pub push_store: push::PushSubscriptionStore,
pub notify_poll: Arc<tokio::sync::Notify>,
}
async fn auth_middleware(
state: axum::extract::State<AppState>,
_state: axum::extract::State<AppState>,
jar: CookieJar,
request: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
// Skip auth for public paths
let path = request.uri().path();
if path.starts_with("/api/auth/login")
|| path.starts_with("/api/auth/check") // Used by frontend to decide where to go
|| path.starts_with("/api/setup")
|| path.starts_with("/api/server_fns")
if path.starts_with("/api/server_fns/Login") // Login server fn
|| path.starts_with("/api/server_fns/GetSetupStatus")
|| path.starts_with("/api/server_fns/Setup")
|| path.starts_with("/swagger-ui")
|| path.starts_with("/api-docs")
|| !path.starts_with("/api/") // Allow static files (frontend)
@@ -69,9 +66,19 @@ async fn auth_middleware(
// Check token
if let Some(token) = jar.get("auth_token") {
match state.db.get_session_user(token.value()).await {
Ok(Some(_)) => return Ok(next.run(request).await),
_ => {} // Invalid
use jsonwebtoken::{decode, Validation, DecodingKey};
use shared::server_fns::auth::Claims;
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
let validation = Validation::default();
match decode::<Claims>(
token.value(),
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
) {
Ok(_) => return Ok(next.run(request).await),
Err(_) => {} // Invalid token
}
}
@@ -103,28 +110,8 @@ struct Args {
}
#[cfg(feature = "swagger")]
#[cfg(feature = "push-notifications")]
#[derive(OpenApi)]
#[openapi(
paths(
handlers::add_torrent_handler,
handlers::handle_torrent_action,
handlers::get_version_handler,
handlers::get_files_handler,
handlers::get_peers_handler,
handlers::get_trackers_handler,
handlers::set_file_priority_handler,
handlers::set_label_handler,
handlers::get_global_limit_handler,
handlers::set_global_limit_handler,
handlers::get_push_public_key_handler,
handlers::subscribe_push_handler,
handlers::auth::login_handler,
handlers::auth::logout_handler,
handlers::auth::check_auth_handler,
handlers::setup::setup_handler,
handlers::setup::get_setup_status_handler
),
components(
schemas(
shared::AddTorrentRequest,
@@ -137,12 +124,6 @@ struct Args {
shared::SetFilePriorityRequest,
shared::SetLabelRequest,
shared::GlobalLimitRequest,
push::PushSubscription,
push::PushKeys,
handlers::auth::LoginRequest,
handlers::setup::SetupRequest,
handlers::setup::SetupStatusResponse,
handlers::auth::UserResponse
)
),
tags(
@@ -151,50 +132,6 @@ struct Args {
)]
struct ApiDoc;
#[cfg(feature = "swagger")]
#[cfg(not(feature = "push-notifications"))]
#[derive(OpenApi)]
#[openapi(
paths(
handlers::add_torrent_handler,
handlers::handle_torrent_action,
handlers::get_version_handler,
handlers::get_files_handler,
handlers::get_peers_handler,
handlers::get_trackers_handler,
handlers::set_file_priority_handler,
handlers::set_label_handler,
handlers::get_global_limit_handler,
handlers::set_global_limit_handler,
handlers::auth::login_handler,
handlers::auth::logout_handler,
handlers::auth::check_auth_handler,
handlers::setup::setup_handler,
handlers::setup::get_setup_status_handler
),
components(
schemas(
shared::AddTorrentRequest,
shared::TorrentActionRequest,
shared::Torrent,
shared::TorrentStatus,
shared::TorrentFile,
shared::TorrentPeer,
shared::TorrentTracker,
shared::SetFilePriorityRequest,
shared::SetLabelRequest,
shared::GlobalLimitRequest,
handlers::auth::LoginRequest,
handlers::setup::SetupRequest,
handlers::setup::SetupStatusResponse,
handlers::auth::UserResponse
)
),
tags(
(name = "vibetorrent", description = "VibeTorrent API")
)
)]
struct ApiDoc;
#[tokio::main]
async fn main() {
@@ -214,19 +151,9 @@ async fn main() {
// Initialize Database
tracing::info!("Connecting to database: {}", args.db_url);
// Ensure the db file exists if it's sqlite
if args.db_url.starts_with("sqlite:") {
let path = args.db_url.trim_start_matches("sqlite:");
if !std::path::Path::new(path).exists() {
tracing::info!("Database file not found, creating: {}", path);
match std::fs::File::create(path) {
Ok(_) => tracing::info!("Created empty database file"),
Err(e) => tracing::error!("Failed to create database file: {}", e),
}
}
}
// Redundant manual creation removed, shared::db handles it
let db: db::Db = match db::Db::new(&args.db_url).await {
let db: shared::db::Db = match shared::db::Db::new(&args.db_url).await {
Ok(db) => db,
Err(e) => {
tracing::error!("Failed to connect to database: {}", e);
@@ -336,7 +263,7 @@ async fn main() {
};
#[cfg(not(feature = "push-notifications"))]
let push_store = ();
let _push_store = ();
let notify_poll = Arc::new(tokio::sync::Notify::new());
@@ -408,10 +335,7 @@ async fn main() {
match diff::diff_torrents(&previous_torrents, &new_torrents) {
diff::DiffResult::FullUpdate => {
let _ = event_bus_tx.send(AppEvent::FullList {
torrents: new_torrents.clone(),
timestamp: now,
});
let _ = event_bus_tx.send(AppEvent::FullList(new_torrents.clone(), now));
}
diff::DiffResult::Partial(updates) => {
for update in updates {
@@ -488,54 +412,32 @@ async fn main() {
#[cfg(feature = "swagger")]
let app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));
// Setup & Auth Routes
// Setup & Auth Routes (cookie-based, stay as REST)
// Setup & Auth Routes (cookie-based, stay as REST)
let scgi_path_for_ctx = args.socket.clone();
let db_for_ctx = db.clone();
let app = app
.route("/api/setup/status", get(handlers::setup::get_setup_status_handler))
.route("/api/setup", post(handlers::setup::setup_handler))
.route(
"/api/auth/login",
post(handlers::auth::login_handler).layer(GovernorLayer::new(Arc::new(
rate_limit::get_login_rate_limit_config(),
))),
)
.route("/api/auth/logout", post(handlers::auth::logout_handler))
.route("/api/auth/check", get(handlers::auth::check_auth_handler))
// App Routes
.route("/api/events", get(sse::sse_handler))
.route("/api/torrents/add", post(handlers::add_torrent_handler))
.route(
"/api/torrents/action",
post(handlers::handle_torrent_action),
)
.route("/api/system/version", get(handlers::get_version_handler))
.route(
"/api/torrents/{hash}/files",
get(handlers::get_files_handler),
)
.route(
"/api/torrents/{hash}/peers",
get(handlers::get_peers_handler),
)
.route(
"/api/torrents/{hash}/trackers",
get(handlers::get_trackers_handler),
)
.route(
"/api/torrents/files/priority",
post(handlers::set_file_priority_handler),
)
.route("/api/torrents/label", post(handlers::set_label_handler))
.route(
"/api/settings/global-limits",
get(handlers::get_global_limit_handler).post(handlers::set_global_limit_handler),
)
.route("/api/server_fns/{*fn_name}", post(leptos_axum::handle_server_fns))
.fallback(handlers::static_handler); // Serve static files for everything else
#[cfg(feature = "push-notifications")]
let app = app
.route("/api/push/public-key", get(handlers::get_push_public_key_handler))
.route("/api/push/subscribe", post(handlers::subscribe_push_handler));
.route("/api/server_fns/{*fn_name}", post({
let scgi_path = scgi_path_for_ctx.clone();
let db = db_for_ctx.clone();
move |req: Request<Body>| {
let scgi_path = scgi_path.clone();
let db = db.clone();
leptos_axum::handle_server_fns_with_context(
move || {
leptos::context::provide_context(shared::ServerContext {
scgi_socket_path: scgi_path.clone(),
});
leptos::context::provide_context(shared::DbContext {
db: db.clone(),
});
},
req,
)
}
}))
.fallback(handlers::static_handler);
let app = app
.layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware))

View File

@@ -7,7 +7,7 @@ use web_push::{
};
use futures::StreamExt;
use crate::db::Db;
use shared::db::Db;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PushSubscription {

View File

@@ -1,16 +1,3 @@
use governor::clock::QuantaInstant;
use governor::middleware::NoOpMiddleware;
use tower_governor::governor::GovernorConfig;
use tower_governor::governor::GovernorConfigBuilder;
use tower_governor::key_extractor::SmartIpKeyExtractor;
pub fn get_login_rate_limit_config() -> GovernorConfig<SmartIpKeyExtractor, NoOpMiddleware<QuantaInstant>> {
// 5 yanlış denemeden sonra bloklanır.
// Her yeni hak için 60 saniye (1 dakika) bekleme süresi.
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_second(60)
.burst_size(5)
.finish()
.unwrap()
}
// This file can be removed or repurposed if rate limiting is needed for other endpoints.
// Login rate limiting is now handled within the server function or needs to be reimplemented
// as a middleware for the server function endpoint.

View File

@@ -4,10 +4,12 @@ use shared::xmlrpc::{
use crate::AppState;
use axum::extract::State;
use axum::response::sse::{Event, Sse};
use futures::stream::{self, Stream};
use futures::stream::{self};
use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus};
use std::convert::Infallible;
use tokio_stream::StreamExt;
use axum::response::IntoResponse;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
// Field definitions to keep query and parser in sync
mod fields {
@@ -194,7 +196,7 @@ pub async fn fetch_global_stats(client: &RtorrentClient) -> Result<GlobalStats,
pub async fn sse_handler(
State(state): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
) -> impl IntoResponse {
// Notify background worker to wake up and poll immediately
state.notify_poll.notify_one();
@@ -208,13 +210,10 @@ pub async fn sse_handler(
.unwrap()
.as_secs();
let event_data = AppEvent::FullList {
torrents: initial_torrents,
timestamp,
};
let event_data = AppEvent::FullList(initial_torrents, timestamp);
match serde_json::to_string(&event_data) {
Ok(json) => Event::default().data(json),
match rmp_serde::to_vec(&event_data) {
Ok(bytes) => Event::default().data(BASE64.encode(bytes)),
Err(_) => Event::default().comment("init_error"),
}
};
@@ -226,10 +225,10 @@ pub async fn sse_handler(
let rx = state.event_bus.subscribe();
let update_stream = stream::unfold(rx, |mut rx| async move {
match rx.recv().await {
Ok(event) => match serde_json::to_string(&event) {
Ok(json) => Some((Ok::<Event, Infallible>(Event::default().data(json)), rx)),
Ok(event) => match rmp_serde::to_vec(&event) {
Ok(bytes) => Some((Ok::<Event, Infallible>(Event::default().data(BASE64.encode(bytes))), rx)),
Err(e) => {
tracing::warn!("Failed to serialize SSE event: {}", e);
tracing::warn!("Failed to serialize SSE event (MessagePack): {}", e);
Some((
Ok::<Event, Infallible>(Event::default().comment("error")),
rx,
@@ -244,6 +243,11 @@ pub async fn sse_handler(
}
});
Sse::new(initial_stream.chain(update_stream))
.keep_alive(axum::response::sse::KeepAlive::default())
}
let sse = Sse::new(initial_stream.chain(update_stream))
.keep_alive(axum::response::sse::KeepAlive::default());
(
[("content-type", "text/event-stream")],
sse
)
}

1
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

View File

@@ -7,7 +7,7 @@ edition = "2021"
crate-type = ["cdylib", "rlib"]
[dependencies]
leptos = { version = "0.8.15", features = ["csr"] }
leptos = { version = "0.8.15", features = ["csr", "msgpack"] }
leptos_router = { version = "0.8.11" }
console_error_panic_hook = "0.1"
@@ -31,3 +31,25 @@ serde-wasm-bindgen = "0.6.5"
leptos-use = { version = "0.16", features = ["storage"] }
codee = "0.3"
thiserror = "2.0"
rmp-serde = "1.3"
struct-patch = "0.5"
# 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"
leptos-shadcn-skeleton = "0.8"

View File

@@ -1,101 +1,102 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>VibeTorrent</title>
<!-- PWA & Mobile Capable -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="VibeTorrent" />
<meta name="theme-color" content="#111827" />
<link rel="manifest" href="manifest.json" />
<link rel="icon" type="image/png" href="icon-192.png" />
<link rel="apple-touch-icon" href="icon-192.png" />
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<head>
<meta charset="utf-8" />
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>VibeTorrent</title>
<!-- Trunk Assets -->
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" />
<link data-trunk rel="css" href="public/tailwind.css" />
<link data-trunk rel="copy-file" href="manifest.json" />
<link data-trunk rel="copy-file" href="icon-192.png" />
<link data-trunk rel="copy-file" href="icon-512.png" />
<link data-trunk rel="copy-file" href="sw.js" />
<script>
(function () {
var localTheme = localStorage.getItem("vibetorrent_theme");
var t = localTheme || "dark";
if (t === "Amoled") t = "black";
if (t === "Light") t = "light";
if (t === "Dark" || t === "Midnight") t = "dark";
<!-- PWA & Mobile Capable -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="VibeTorrent" />
<meta name="theme-color" content="#111827" />
<link rel="manifest" href="manifest.json" />
<link rel="icon" type="image/png" href="icon-192.png" />
<link rel="apple-touch-icon" href="icon-192.png" />
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
var theme = t.toLowerCase();
document.documentElement.setAttribute("data-theme", theme);
if (!localTheme) {
localStorage.setItem("vibetorrent_theme", "dark");
}
<!-- Trunk Assets -->
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" />
<link data-trunk rel="css" href="public/tailwind.css" />
<link data-trunk rel="copy-file" href="manifest.json" />
<link data-trunk rel="copy-file" href="icon-192.png" />
<link data-trunk rel="copy-file" href="icon-512.png" />
<link data-trunk rel="copy-file" href="sw.js" />
<script>
(function () {
var localTheme = localStorage.getItem("vibetorrent_theme");
var t = localTheme || "dark";
if (t === "Amoled") t = "black";
if (t === "Light") t = "light";
if (t === "Dark" || t === "Midnight") t = "dark";
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
var colorMap = {
light: "#ffffff",
cupcake: "#faf7f5",
bumblebee: "#ffffff",
emerald: "#ffffff",
corporate: "#ffffff",
synthwave: "#2d1b69",
retro: "#ece3ca",
cyberpunk: "#ffee00",
valentine: "#f0d6e8",
halloween: "#212121",
garden: "#e9e7e7",
forest: "#171212",
aqua: "#345da7",
lofi: "#ffffff",
pastel: "#ffffff",
fantasy: "#ffffff",
wireframe: "#ffffff",
black: "#000000",
luxury: "#09090b",
dracula: "#282a36",
cmyk: "#ffffff",
autumn: "#8C0327",
business: "#202020",
acid: "#fafafa",
lemonade: "#F1F8E8",
night: "#0f1729",
coffee: "#20161f",
winter: "#ffffff",
dark: "#1d232a",
};
var color = colorMap[theme] || "#1d232a";
meta.setAttribute("content", color);
}
})();
</script>
</head>
var theme = t.toLowerCase();
document.documentElement.setAttribute("data-theme", theme);
// Shadcn dark mode CSS değişkenleri .dark class ile çalışıyor
var darkThemes = ["dark", "black", "night", "coffee", "luxury", "business", "dracula", "halloween", "forest", "synthwave", "dim", "nord", "sunset", "cyberpunk", "abyss"];
if (darkThemes.indexOf(theme) !== -1) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
if (!localTheme) {
localStorage.setItem("vibetorrent_theme", "dark");
}
<body style="cursor: pointer;">
<div
id="app-loading"
style="
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
var colorMap = {
light: "#ffffff",
cupcake: "#faf7f5",
bumblebee: "#ffffff",
emerald: "#ffffff",
corporate: "#ffffff",
synthwave: "#2d1b69",
retro: "#ece3ca",
cyberpunk: "#ffee00",
valentine: "#f0d6e8",
halloween: "#212121",
garden: "#e9e7e7",
forest: "#171212",
aqua: "#345da7",
lofi: "#ffffff",
pastel: "#ffffff",
fantasy: "#ffffff",
wireframe: "#ffffff",
black: "#000000",
luxury: "#09090b",
dracula: "#282a36",
cmyk: "#ffffff",
autumn: "#8C0327",
business: "#202020",
acid: "#fafafa",
lemonade: "#F1F8E8",
night: "#0f1729",
coffee: "#20161f",
winter: "#ffffff",
dark: "#1d232a",
};
var color = colorMap[theme] || "#1d232a";
meta.setAttribute("content", color);
}
})();
</script>
</head>
<body style="cursor: pointer;">
<div id="app-loading" style="
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
font-family: sans-serif;
"
>
<div
id="app-loading-spinner"
style="
">
<div id="app-loading-spinner" style="
width: 40px;
height: 40px;
border: 3px solid currentColor;
@@ -103,21 +104,15 @@
border-radius: 50%;
animation: spin 0.8s linear infinite;
opacity: 0.5;
"
></div>
<div
id="app-loading-error"
style="display: none; text-align: center; margin-top: 20px; padding: 0 20px"
>
<p style="color: #ef4444; font-weight: bold; margin-bottom: 8px">
Uygulama yüklenemedi
</p>
<p style="font-size: 14px; opacity: 0.7">
Bağlantınız yavaş olabilir veya bir sistem hatası oluşmuş olabilir.
</p>
<button
onclick="location.reload()"
style="
"></div>
<div id="app-loading-error" style="display: none; text-align: center; margin-top: 20px; padding: 0 20px">
<p style="color: #ef4444; font-weight: bold; margin-bottom: 8px">
Uygulama yüklenemedi
</p>
<p style="font-size: 14px; opacity: 0.7">
Bağlantınız yavaş olabilir veya bir sistem hatası oluşmuş olabilir.
</p>
<button onclick="location.reload()" style="
margin-top: 16px;
padding: 8px 16px;
background: #3b82f6;
@@ -126,104 +121,105 @@
border-radius: 6px;
cursor: pointer;
font-weight: 500;
"
>
Sayfayı Yenile
</button>
</div>
">
Sayfayı Yenile
</button>
</div>
<style>
@keyframes spin {
to {
transform: rotate(360deg);
</div>
<style>
@keyframes spin {
to {
transform: rotate(360deg);
}
}
body.app-loaded #app-loading {
display: none !important;
}
/* iOS Safari Click Fixes */
body {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
summary {
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
</style>
<script>
// App loading timeout handler
(function () {
var timeout = setTimeout(function () {
if (!document.body.classList.contains("app-loaded")) {
var spinner = document.getElementById("app-loading-spinner");
var error = document.getElementById("app-loading-error");
if (spinner) spinner.style.display = "none";
if (error) error.style.display = "block";
}
}
}, 15000); // 15 seconds timeout
body.app-loaded #app-loading {
display: none !important;
}
/* iOS Safari Click Fixes */
body {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
summary {
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
</style>
<script>
// App loading timeout handler
(function () {
var timeout = setTimeout(function () {
if (!document.body.classList.contains("app-loaded")) {
var spinner = document.getElementById("app-loading-spinner");
var error = document.getElementById("app-loading-error");
if (spinner) spinner.style.display = "none";
if (error) error.style.display = "block";
}
}, 15000); // 15 seconds timeout
// Clean up timeout if app loads
var observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (
mutation.attributeName === "class" &&
document.body.classList.contains("app-loaded")
) {
clearTimeout(timeout);
observer.disconnect();
}
});
});
observer.observe(document.body, { attributes: true });
})();
</script>
<!-- Service Worker Registration & PWA Setup -->
<script>
// Global Dropdown Closer for iOS/Mobile
document.addEventListener('click', function(event) {
const details = document.querySelectorAll('details[open]');
details.forEach(detail => {
// Eğer tıklanan yer bu details'in içinde değilse kapat
if (!detail.contains(event.target)) {
detail.removeAttribute('open');
// Clean up timeout if app loads
var observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (
mutation.attributeName === "class" &&
document.body.classList.contains("app-loaded")
) {
clearTimeout(timeout);
observer.disconnect();
}
});
}, true); // Use capture phase for better mobile support
});
observer.observe(document.body, { attributes: true });
})();
</script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("✅ Service Worker registered:", registration);
// Request notification permission after a delay (better UX)
setTimeout(() => {
if ("Notification" in window && Notification.permission === "default") {
// Only request if user hasn't decided yet
const shouldRequest = localStorage.getItem("vibetorrent_notification_prompt_shown");
if (!shouldRequest) {
Notification.requestPermission().then((permission) => {
console.log("Notification permission:", permission);
localStorage.setItem("vibetorrent_notification_prompt_shown", "true");
});
}
<!-- Service Worker Registration & PWA Setup -->
<script>
// Global Dropdown Closer for iOS/Mobile
document.addEventListener('click', function (event) {
const details = document.querySelectorAll('details[open]');
details.forEach(detail => {
// Eğer tıklanan yer bu details'in içinde değilse kapat
if (!detail.contains(event.target)) {
detail.removeAttribute('open');
}
});
}, true); // Use capture phase for better mobile support
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("✅ Service Worker registered:", registration);
// Request notification permission after a delay (better UX)
setTimeout(() => {
if ("Notification" in window && Notification.permission === "default") {
// Only request if user hasn't decided yet
const shouldRequest = localStorage.getItem("vibetorrent_notification_prompt_shown");
if (!shouldRequest) {
Notification.requestPermission().then((permission) => {
console.log("Notification permission:", permission);
localStorage.setItem("vibetorrent_notification_prompt_shown", "true");
});
}
}, 3000); // Wait 3 seconds before asking
})
.catch((error) => {
console.warn("⚠️ Service Worker registration failed:", error);
});
});
}
</script>
</body>
</html>
}
}, 3000); // Wait 3 seconds before asking
})
.catch((error) => {
console.warn("⚠️ Service Worker registration failed:", error);
});
});
}
</script>
</body>
</html>

View File

@@ -1,16 +1,161 @@
@import "tailwindcss";
@config "./tailwind.config.js";
@source "../src/**/*.rs";
@source "/Users/bilal/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/leptos-shadcn-*/src/**/*.rs";
@plugin "daisyui" {
themes:
light, dark, dim, nord, cupcake, dracula, cyberpunk, emerald, sunset,
abyss;
@theme {
/* Shadcn Colors */
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
@layer base {
html,
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply min-h-dvh w-full overflow-hidden bg-base-100 text-base-content overscroll-y-none;
@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;
}
}
@@ -29,4 +174,4 @@
:focus {
outline: none !important;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,11 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"autoprefixer": "^10.4.23",
"daisyui": "^5.5.1-beta.2",
"postcss": "^8.5.6",
"postcss-cli": "^11.0.1",
"postcss-preset-env": "^10.1.3",
"tailwindcss": "^4.1.18"
},
"dependencies": {

View File

@@ -0,0 +1,15 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
"postcss-preset-env": {
features: {
"nesting-rules": true,
},
browsers: [
"last 2 versions",
"iOS >= 15",
"Safari >= 15",
],
},
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
use gloo_net::http::Request;
use shared::{AddTorrentRequest, TorrentActionRequest};
use thiserror::Error;
#[derive(Debug, Error)]
@@ -14,6 +13,8 @@ pub enum ApiError {
Unauthorized,
#[error("Too many requests")]
RateLimited,
#[error("Server function error: {0}")]
ServerFn(String),
}
fn base_url() -> String {
@@ -130,37 +131,32 @@ pub mod settings {
use shared::GlobalLimitRequest;
pub async fn set_global_limits(req: &GlobalLimitRequest) -> Result<(), ApiError> {
Request::post(&format!("{}/settings/global-limits", base_url()))
.json(req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
shared::server_fns::settings::set_global_limits(
req.max_download_rate,
req.max_upload_rate,
)
.await
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
}
pub mod push {
use super::*;
use crate::store::PushSubscriptionData;
pub async fn get_public_key() -> Result<String, ApiError> {
let resp = Request::get(&format!("{}/push/public-key", base_url()))
.send()
shared::server_fns::push::get_public_key()
.await
.map_err(|_| ApiError::Network)?;
let key = resp.text().await.map_err(|_| ApiError::Network)?;
Ok(key)
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
pub async fn subscribe(req: &PushSubscriptionData) -> Result<(), ApiError> {
Request::post(&format!("{}/push/subscribe", base_url()))
.json(req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
pub async fn subscribe(endpoint: &str, p256dh: &str, auth: &str) -> Result<(), ApiError> {
shared::server_fns::push::subscribe_push(
endpoint.to_string(),
p256dh.to_string(),
auth.to_string(),
)
.await
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
}
@@ -168,30 +164,16 @@ pub mod torrent {
use super::*;
pub async fn add(uri: &str) -> Result<(), ApiError> {
let req = AddTorrentRequest {
uri: uri.to_string(),
};
Request::post(&format!("{}/torrents/add", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
shared::server_fns::torrent::add_torrent(uri.to_string())
.await
.map_err(|_| ApiError::Network)?;
Ok(())
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
pub async fn action(hash: &str, action: &str) -> Result<(), ApiError> {
let req = TorrentActionRequest {
hash: hash.to_string(),
action: action.to_string(),
};
Request::post(&format!("{}/torrents/action", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
shared::server_fns::torrent::torrent_action(hash.to_string(), action.to_string())
.await
.map_err(|_| ApiError::Network)?;
Ok(())
.map(|_| ())
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
pub async fn delete(hash: &str) -> Result<(), ApiError> {
@@ -211,33 +193,18 @@ pub mod torrent {
}
pub async fn set_label(hash: &str, label: &str) -> Result<(), ApiError> {
use shared::SetLabelRequest;
let req = SetLabelRequest {
hash: hash.to_string(),
label: label.to_string(),
};
Request::post(&format!("{}/torrents/set_label", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
shared::server_fns::torrent::set_label(hash.to_string(), label.to_string())
.await
.map_err(|_| ApiError::Network)?;
Ok(())
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
pub async fn set_priority(hash: &str, file_index: u32, priority: u8) -> Result<(), ApiError> {
use shared::SetFilePriorityRequest;
let req = SetFilePriorityRequest {
hash: hash.to_string(),
shared::server_fns::torrent::set_file_priority(
hash.to_string(),
file_index,
priority,
};
Request::post(&format!("{}/torrents/set_priority", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
)
.await
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
}

View File

@@ -1,31 +1,43 @@
use crate::components::layout::protected::Protected;
use crate::components::toast::ToastContainer;
use crate::components::torrent::table::TorrentTable;
use crate::components::torrent::detail::TorrentDetail;
use crate::components::auth::login::Login;
use crate::components::auth::setup::Setup;
use crate::api;
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_router::components::{Router, Routes, Route};
use leptos_router::hooks::use_navigate;
use leptos_shadcn_skeleton::Skeleton;
use leptos_shadcn_toast::SonnerProvider;
#[component]
pub fn App() -> impl IntoView {
view! {
<SonnerProvider>
<InnerApp />
</SonnerProvider>
}
}
#[component]
fn InnerApp() -> impl IntoView {
crate::store::provide_torrent_store();
let store = use_context::<crate::store::TorrentStore>();
let is_loading = signal(true);
let is_authenticated = signal(false);
let needs_setup = signal(false);
Effect::new(move |_| {
spawn_local(async move {
log::info!("App initialization started...");
let setup_res = api::setup::get_status().await;
match setup_res {
// Check if setup is needed via Server Function
match shared::server_fns::auth::get_setup_status().await {
Ok(status) => {
if !status.completed {
log::info!("Setup not completed");
needs_setup.1.set(true);
is_loading.1.set(false);
return;
}
@@ -33,21 +45,16 @@ pub fn App() -> impl IntoView {
Err(e) => log::error!("Failed to get setup status: {:?}", e),
}
let auth_res = api::auth::check_auth().await;
match auth_res {
Ok(true) => {
log::info!("Authenticated!");
if let Ok(user_info) = api::auth::get_user().await {
if let Some(store) = use_context::<crate::store::TorrentStore>() {
store.user.set(Some(user_info.username));
}
// Check authentication via GetUser Server Function
match shared::server_fns::auth::get_user().await {
Ok(Some(user_info)) => {
log::info!("Authenticated as {}", user_info.username);
if let Some(s) = store {
s.user.set(Some(user_info.username));
}
is_authenticated.1.set(true);
}
Ok(false) => {
Ok(None) => {
log::info!("Not authenticated");
}
Err(e) => {
@@ -56,6 +63,7 @@ pub fn App() -> impl IntoView {
}
is_loading.1.set(false);
crate::store::toast_success("VibeTorrent'e Hoşgeldiniz");
});
});
@@ -77,9 +85,13 @@ pub fn App() -> impl IntoView {
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
<Route path=leptos_router::path!("/login") view=move || {
let authenticated = is_authenticated.0.get();
let setup_needed = needs_setup.0.get();
Effect::new(move |_| {
if authenticated {
if setup_needed {
let navigate = use_navigate();
navigate("/setup", Default::default());
} else if authenticated {
log::info!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
@@ -100,27 +112,70 @@ pub fn App() -> impl IntoView {
} />
<Route path=leptos_router::path!("/") view=move || {
let navigate = use_navigate();
Effect::new(move |_| {
if !is_loading.0.get() && !is_authenticated.0.get() {
log::info!("Not authenticated, redirecting to login");
let navigate = use_navigate();
navigate("/login", Default::default());
if !is_loading.0.get() {
if needs_setup.0.get() {
log::info!("Setup not completed, redirecting to setup");
navigate("/setup", Default::default());
} else if !is_authenticated.0.get() {
log::info!("Not authenticated, redirecting to login");
navigate("/login", Default::default());
}
}
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| view! {
<div class="flex items-center justify-center h-screen bg-base-100">
<span class="loading loading-spinner loading-lg"></span>
<div class="flex h-screen bg-background">
// Sidebar skeleton
<div class="w-56 border-r border-border p-4 space-y-4">
<Skeleton class="h-8 w-3/4" />
<div class="space-y-2">
<Skeleton class="h-6 w-full" />
<Skeleton class="h-6 w-full" />
<Skeleton class="h-6 w-4/5" />
<Skeleton class="h-6 w-full" />
<Skeleton class="h-6 w-3/5" />
<Skeleton class="h-6 w-full" />
</div>
</div>
// Main content skeleton
<div class="flex-1 flex flex-col">
// Header skeleton
<div class="border-b border-border p-4 flex items-center gap-4">
<Skeleton class="h-8 w-48" />
<Skeleton class="h-8 w-64" />
<div class="ml-auto"><Skeleton class="h-8 w-24" /></div>
</div>
// Table skeleton rows
<div class="flex-1 p-4 space-y-3">
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-3/4" />
</div>
// Status bar skeleton
<div class="border-t border-border p-3">
<Skeleton class="h-5 w-96" />
</div>
</div>
</div>
}>
}.into_any()>
<Show when=move || is_authenticated.0.get() fallback=|| ()>
<Protected>
<TorrentTable />
<div class="flex flex-col h-full overflow-hidden">
<div class="flex-1 overflow-hidden">
<TorrentTable />
</div>
<TorrentDetail />
</div>
</Protected>
</Show>
</Show>
}
}.into_any()
}/>
<Route path=leptos_router::path!("/settings") view=move || {
@@ -143,8 +198,6 @@ pub fn App() -> impl IntoView {
}/>
</Routes>
</Router>
<ToastContainer />
</div>
}
}

View File

@@ -1,12 +1,15 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::api;
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]
pub fn Login() -> impl IntoView {
let username = signal(String::new());
let password = signal(String::new());
let remember_me = signal(false);
let error = signal(Option::<String>::None);
let loading = signal(false);
@@ -17,19 +20,14 @@ pub fn Login() -> impl IntoView {
let user = username.0.get();
let pass = password.0.get();
let rem = remember_me.0.get();
log::info!("Attempting login for user: {}", user);
spawn_local(async move {
match api::auth::login(&user, &pass, rem).await {
match shared::server_fns::auth::login(user, pass).await {
Ok(_) => {
log::info!("Login successful, redirecting...");
let window = web_sys::window().expect("window should exist");
let _ = window.location().set_href("/");
}
Err(e) => {
log::error!("Login failed: {:?}", e);
Err(_) => {
error.1.set(Some("Geçersiz kullanıcı adı veya şifre".to_string()));
loading.1.set(false);
}
@@ -38,82 +36,64 @@ pub fn Login() -> impl IntoView {
};
view! {
<div class="flex items-center justify-center min-h-screen bg-base-200">
<div class="card w-full max-w-sm shadow-xl bg-base-100">
<div class="card-body">
<div class="flex flex-col items-center mb-6">
<div class="w-16 h-16 bg-primary rounded-2xl flex items-center justify-center text-primary-content shadow-lg 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-10 h-10">
<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="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z" />
</svg>
</div>
<h2 class="card-title text-2xl font-bold">"VibeTorrent"</h2>
<p class="text-base-content/60 text-sm">"Hesabınıza giriş yapın"</p>
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4">
<Card class="w-full max-w-sm shadow-lg">
<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">
<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="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z" />
</svg>
</div>
<h3 class="font-semibold tracking-tight text-2xl">"VibeTorrent"</h3>
<p class="text-sm text-muted-foreground">"Hesabınıza giriş yapın"</p>
</CardHeader>
<CardContent class="pt-4">
<form on:submit=handle_login class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text">"Kullanıcı Adı"</span>
</label>
<input
type="text"
placeholder="Kullanıcı adınız"
class="input input-bordered w-full"
prop:value=move || username.0.get()
on:input=move |ev| username.1.set(event_target_value(&ev))
disabled=move || loading.0.get()
required
<div class="space-y-2">
<Label>"Kullanıcı Adı"</Label>
<Input
input_type="text"
placeholder="Kullanıcı adınız"
value=MaybeProp::derive(move || Some(username.0.get()))
on_change=Callback::new(move |val: String| username.1.set(val))
disabled=Signal::derive(move || loading.0.get())
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">"Şifre"</span>
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
prop:value=move || password.0.get()
on:input=move |ev| password.1.set(event_target_value(&ev))
disabled=move || loading.0.get()
required
<div class="space-y-2">
<Label>"Şifre"</Label>
<Input
input_type="password"
placeholder="******"
value=MaybeProp::derive(move || Some(password.0.get()))
on_change=Callback::new(move |val: String| password.1.set(val))
disabled=Signal::derive(move || loading.0.get())
/>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
prop:checked=move || remember_me.0.get()
on:change=move |ev| remember_me.1.set(event_target_checked(&ev))
/>
<span class="label-text">"Beni hatırla"</span>
</label>
</div>
<Show when=move || error.0.get().is_some() fallback=|| ()>
<div class="alert alert-error text-xs py-2 shadow-sm">
<span>{move || error.0.get().unwrap_or_default()}</span>
</div>
<Show when=move || error.0.get().is_some()>
<Alert variant=AlertVariant::Destructive>
<AlertDescription>
{move || error.0.get().unwrap_or_default()}
</AlertDescription>
</Alert>
</Show>
<div class="form-control mt-6">
<button
class="btn btn-primary w-full"
type="submit"
disabled=move || loading.0.get()
<div class="pt-2">
<Button
class="w-full"
disabled=Signal::derive(move || loading.0.get())
>
<Show when=move || loading.0.get() fallback=|| "Giriş Yap">
<span class="loading loading-spinner"></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..."
</Show>
</button>
</Button>
</div>
</form>
</div>
</div>
</CardContent>
</Card>
</div>
}
}

View File

@@ -1,6 +1,10 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::api;
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]
pub fn Setup() -> impl IntoView {
@@ -32,7 +36,7 @@ pub fn Setup() -> impl IntoView {
let user = username.0.get();
spawn_local(async move {
match api::setup::setup(&user, &pass).await {
match shared::server_fns::auth::setup(user, pass).await {
Ok(_) => {
log::info!("Setup completed successfully, redirecting...");
let window = web_sys::window().expect("window should exist");
@@ -40,7 +44,7 @@ pub fn Setup() -> impl IntoView {
}
Err(e) => {
log::error!("Setup failed: {:?}", e);
error.1.set(Some(format!("Hata: {:?}", e)));
error.1.set(Some("Kurulum sırasında bir hata oluştu".to_string()));
loading.1.set(false);
}
}
@@ -48,83 +52,73 @@ pub fn Setup() -> impl IntoView {
};
view! {
<div class="flex items-center justify-center min-h-screen bg-base-200">
<div class="card w-full max-w-md shadow-xl bg-base-100">
<div class="card-body">
<div class="flex flex-col items-center mb-6 text-center">
<div class="w-16 h-16 bg-primary rounded-2xl flex items-center justify-center text-primary-content shadow-lg 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-10 h-10">
<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" />
</svg>
</div>
<h2 class="card-title text-2xl font-bold">"VibeTorrent Kurulumu"</h2>
<p class="text-base-content/60 text-sm">"Yönetici hesabınızı oluşturun"</p>
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4">
<Card class="w-full max-w-md shadow-lg overflow-hidden">
<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">
<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" />
</svg>
</div>
<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>
</CardHeader>
<CardContent class="pt-4">
<form on:submit=handle_setup class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text">"Yönetici Kullanıcı Adı"</span>
</label>
<input
type="text"
placeholder="admin"
class="input input-bordered w-full"
prop:value=move || username.0.get()
on:input=move |ev| username.1.set(event_target_value(&ev))
disabled=move || loading.0.get()
required
<div class="space-y-2">
<Label>"Yönetici Kullanıcı Adı"</Label>
<Input
input_type="text"
placeholder="admin"
value=MaybeProp::derive(move || Some(username.0.get()))
on_change=Callback::new(move |val: String| username.1.set(val))
disabled=Signal::derive(move || loading.0.get())
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">"Şifre"</span>
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
prop:value=move || password.0.get()
on:input=move |ev| password.1.set(event_target_value(&ev))
disabled=move || loading.0.get()
required
<div class="space-y-2">
<Label>"Şifre"</Label>
<Input
input_type="password"
placeholder="******"
value=MaybeProp::derive(move || Some(password.0.get()))
on_change=Callback::new(move |val: String| password.1.set(val))
disabled=Signal::derive(move || loading.0.get())
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">"Şifre Onay"</span>
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
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 class="space-y-2">
<Label>"Şifre Onay"</Label>
<Input
input_type="password"
placeholder="******"
value=MaybeProp::derive(move || Some(confirm_password.0.get()))
on_change=Callback::new(move |val: String| confirm_password.1.set(val))
disabled=Signal::derive(move || loading.0.get())
/>
</div>
<Show when=move || error.0.get().is_some() fallback=|| ()>
<div class="alert alert-error text-xs py-2 shadow-sm">
<span>{move || error.0.get().unwrap_or_default()}</span>
</div>
<Alert variant=AlertVariant::Destructive>
<AlertDescription>
<span>{move || error.0.get().unwrap_or_default()}</span>
</AlertDescription>
</Alert>
</Show>
<div class="form-control mt-6">
<button
class="btn btn-primary w-full"
type="submit"
disabled=move || loading.0.get()
<div class="pt-2">
<Button
class="w-full"
disabled=Signal::derive(move || loading.0.get())
>
<Show when=move || loading.0.get() fallback=|| "Kurulumu Tamamla">
<span class="loading loading-spinner"></span>
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
"Kuruluyor..."
</Show>
</button>
</Button>
</div>
</form>
</div>
</div>
</CardContent>
</Card>
</div>
}
}
}

View File

@@ -1,97 +1,158 @@
use leptos::prelude::*;
use leptos::html;
use leptos_use::on_click_outside;
use web_sys::MouseEvent;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
fn handle_action(
hash: String,
action: &str,
on_action: Callback<(String, String)>,
on_close: Callback<()>,
) {
log::info!("ContextMenu: Action '{}' for hash '{}'", action, hash);
on_action.run((action.to_string(), hash));
on_close.run(());
}
// ── Kendi reaktif Context Menu implementasyonumuz ──
// leptos-shadcn-context-menu v0.8.1'de ContextMenuContent'te
// `if open.get()` statik kontrolü reaktif değil. Aşağıda
// `Show` bileşeni ile düzgün reaktif versiyon yer alıyor.
#[component]
pub fn ContextMenu(
position: (i32, i32),
pub fn TorrentContextMenu(
children: Children,
torrent_hash: String,
on_close: Callback<()>,
on_action: Callback<(String, String)>,
) -> impl IntoView {
let container_ref = NodeRef::<html::Div>::new();
let _ = on_click_outside(container_ref, move |_| on_close.run(()));
let hash = StoredValue::new(torrent_hash);
let on_action = StoredValue::new(on_action);
let (x, y) = position;
let hash1 = torrent_hash.clone();
let hash2 = torrent_hash.clone();
let hash3 = torrent_hash.clone();
let hash4 = torrent_hash.clone();
let hash5 = torrent_hash;
let open = RwSignal::new(false);
let position = RwSignal::new((0i32, 0i32));
// Sağ tıklama handler
let on_contextmenu = move |e: MouseEvent| {
e.prevent_default();
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! {
<div
node_ref=container_ref
class="fixed z-[100] min-w-[200px] animate-in fade-in zoom-in-95 duration-100"
style=format!("left: {}px; top: {}px;", x, y)
on:contextmenu=move |e| e.prevent_default()
<div
class="w-full"
on:contextmenu=on_contextmenu
>
<ul class="menu bg-base-200 shadow-xl rounded-box border border-base-300 p-1 gap-0.5">
<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>
{children()}
</div>
<Show when=move || open.get()>
{
let (x, y) = position.get();
// Menü yaklaşık boyutları
let menu_width = 200;
let menu_height = 220;
let window = web_sys::window().unwrap();
let vw = window.inner_width().unwrap().as_f64().unwrap() as i32;
let vh = window.inner_height().unwrap().as_f64().unwrap() as i32;
// Sağa taşarsa sola aç, alta taşarsa yukarı
let final_x = if x + menu_width > vw { x - menu_width } else { x };
let final_y = if y + menu_height > vh { y - menu_height } else { y };
let final_x = final_x.max(0);
let final_y = final_y.max(0);
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;", final_x, final_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]
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! {
<div class="drawer lg:drawer-open h-full w-full">
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
<div class="flex h-screen w-full overflow-hidden bg-background">
<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 />
// --- MAIN CONTENT ---
<main class="flex-1 overflow-hidden relative">
<main class="flex-1 overflow-hidden relative bg-background">
{children()}
</main>
// --- STATUS BAR (BOTTOM) ---
<StatusBar />
</div>
// --- SIDEBAR (DRAWER) ---
<div class="drawer-side z-[100]">
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<Sidebar />
</div>
</div>
}
}

View File

@@ -1,11 +1,16 @@
use leptos::prelude::*;
use leptos::wasm_bindgen::JsCast;
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;
use leptos_use::storage::use_local_storage;
use ::codee::string::FromToStringCodec;
#[component]
pub fn Sidebar() -> impl IntoView {
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 downloading_count = move || {
@@ -50,35 +55,12 @@ pub fn Sidebar() -> impl IntoView {
})
};
let close_drawer = move || {
if let Some(element) = document().get_element_by_id("my-drawer") {
if let Ok(input) = element.dyn_into::<web_sys::HtmlInputElement>() {
input.set_checked(false);
}
}
};
let set_filter = move |f: crate::store::FilterStatus| {
store.filter.set(f);
close_drawer();
is_mobile_menu_open.set(false);
};
let filter_class = move |f: crate::store::FilterStatus| {
if store.filter.get() == f {
"active"
} else {
""
}
};
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 is_active = move |f: crate::store::FilterStatus| store.filter.get() == f;
let username = move || {
store.user.get().unwrap_or_else(|| "User".to_string())
@@ -88,90 +70,179 @@ pub fn Sidebar() -> impl IntoView {
username().chars().next().unwrap_or('?').to_uppercase().to_string()
};
// --- THEME LOGIC START ---
let (current_theme, set_current_theme, _) = use_local_storage::<String, FromToStringCodec>("vibetorrent_theme");
// Initialize with default if empty
let current_theme_val = current_theme.get();
if current_theme_val.is_empty() {
set_current_theme.set("dark".to_string());
}
// Automatically sync theme to document attribute
Effect::new(move |_| {
let theme = current_theme.get().to_lowercase();
if let Some(doc) = document().document_element() {
let _ = doc.set_attribute("data-theme", &theme);
// Also set class for Shadcn dark mode support
if theme == "dark" || theme == "dracula" || theme == "dim" || theme == "abyss" || theme == "sunset" || theme == "cyberpunk" || theme == "nord" || theme == "business" || theme == "night" || theme == "black" || theme == "luxury" || theme == "coffee" || theme == "forest" || theme == "halloween" || theme == "synthwave" {
let _ = doc.class_list().add_1("dark");
} else {
let _ = doc.class_list().remove_1("dark");
}
}
});
let toggle_theme = move |_| {
let new_theme = if current_theme.get() == "dark" { "light" } else { "dark" };
set_current_theme.set(new_theme.to_string());
};
// --- THEME LOGIC END ---
view! {
<div class="w-64 min-h-[100dvh] flex flex-col bg-base-200 border-r border-base-300 pb-8" style="padding-top: env(safe-area-inset-top);">
<div class="p-2 flex-1 overflow-y-auto">
<ul class="menu w-full rounded-box gap-1">
<li class="menu-title text-primary uppercase font-bold px-4">"Filters"</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::All))} on:click=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-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
"All"
<span class="badge badge-sm badge-ghost ml-auto">{total_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Downloading))} on:click=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-5 h-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.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
"Downloading"
<span class="badge badge-sm badge-ghost ml-auto">{downloading_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Seeding))} on:click=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-5 h-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>
"Seeding"
<span class="badge badge-sm badge-ghost ml-auto">{seeding_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Completed))} on:click=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-5 h-5">
<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>
"Completed"
<span class="badge badge-sm badge-ghost ml-auto">{completed_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Paused))} on:click=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-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
"Paused"
<span class="badge badge-sm badge-ghost ml-auto">{paused_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Inactive))} on:click=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-5 h-5">
<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>
"Inactive"
<span class="badge badge-sm badge-ghost ml-auto">{inactive_count}</span>
</button>
</li>
</ul>
<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="mb-4 px-2 text-lg font-semibold tracking-tight text-foreground">
"VibeTorrent"
</div>
<div class="space-y-1">
<h4 class="mb-1 rounded-md px-2 py-1 text-sm font-semibold text-muted-foreground">"Filters"</h4>
<Button
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" />
</svg>
"All"
<span class="ml-auto text-xs font-mono opacity-70">{total_count}</span>
</Button>
<Button
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" />
</svg>
"Downloading"
<span class="ml-auto text-xs font-mono opacity-70">{downloading_count}</span>
</Button>
<Button
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" />
</svg>
"Seeding"
<span class="ml-auto text-xs font-mono opacity-70">{seeding_count}</span>
</Button>
<Button
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" />
</svg>
"Completed"
<span class="ml-auto text-xs font-mono opacity-70">{completed_count}</span>
</Button>
<Button
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" />
</svg>
"Paused"
<span class="ml-auto text-xs font-mono opacity-70">{paused_count}</span>
</Button>
<Button
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" />
</svg>
"Inactive"
<span class="ml-auto text-xs font-mono opacity-70">{inactive_count}</span>
</Button>
</div>
</div>
<div class="p-4 border-t border-base-300 bg-base-200/50">
<Separator />
<div class="p-4 bg-card" style="padding-bottom: calc(1rem + env(safe-area-inset-bottom));">
<div class="flex items-center gap-3">
<div class="avatar">
<div class="w-8 rounded-full bg-neutral text-neutral-content ring ring-primary ring-offset-base-100 ring-offset-1">
<span class="text-sm font-bold flex items-center justify-center h-full">{first_letter}</span>
</div>
</div>
<Avatar class="h-8 w-8">
<AvatarFallback class="bg-primary text-primary-foreground text-xs font-medium">
{first_letter}
</AvatarFallback>
</Avatar>
<div class="flex-1 overflow-hidden">
<div class="font-bold text-sm truncate">{username}</div>
<div class="text-[10px] text-base-content/60 truncate">"Online"</div>
<div class="font-medium text-sm truncate text-foreground">{username}</div>
<div class="text-[10px] text-muted-foreground truncate">"Online"</div>
</div>
<button
class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/10"
title="Logout"
on:click=handle_logout
// --- THEME BUTTON ---
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon
class="h-8 w-8 text-muted-foreground hover:text-foreground"
on_click=Callback::new(toggle_theme)
>
// Sun icon for dark mode (to switch to light), Moon for light (to switch to dark)
// Actually show current state or action? Usually action.
// If dark, show Sun. If light, show Moon.
<Show when=move || current_theme.get() == "dark" fallback=|| view! {
<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="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
}>
<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="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</Show>
</Button>
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon
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">
<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>
</button>
</Button>
</div>
</div>
</div>
}
}
}

View File

@@ -1,7 +1,5 @@
use leptos::prelude::*;
use leptos::html;
use leptos_use::storage::use_local_storage;
use ::codee::string::FromToStringCodec;
use shared::GlobalLimitRequest;
use crate::api;
@@ -30,21 +28,7 @@ pub fn StatusBar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let stats = store.global_stats;
let (current_theme, set_current_theme, _) = use_local_storage::<String, FromToStringCodec>("vibetorrent_theme");
// Initialize with default if empty
let current_theme_val = current_theme.get();
if current_theme_val.is_empty() {
set_current_theme.set("dark".to_string());
}
// Automatically sync theme to document attribute
Effect::new(move |_| {
let theme = current_theme.get().to_lowercase();
if let Some(doc) = document().document_element() {
let _ = doc.set_attribute("data-theme", &theme);
}
});
// Preset limits in bytes/s
let limits: Vec<(i64, &str)> = vec!(
@@ -85,7 +69,6 @@ pub fn StatusBar() -> impl IntoView {
let down_details_ref = NodeRef::<html::Details>::new();
let up_details_ref = NodeRef::<html::Details>::new();
let theme_details_ref = NodeRef::<html::Details>::new();
let close_details = move |node_ref: NodeRef<html::Details>| {
if let Some(el) = node_ref.get_untracked() {
@@ -94,11 +77,11 @@ pub fn StatusBar() -> impl IntoView {
};
view! {
<div class="fixed bottom-0 left-0 right-0 h-8 min-h-8 bg-base-200 border-t border-base-300 flex items-center px-4 text-xs gap-4 text-base-content/70 z-[99] cursor-pointer">
<div class="fixed bottom-0 left-0 right-0 h-8 min-h-8 bg-muted border-t border-border flex items-center px-4 text-xs gap-4 text-muted-foreground z-[99] cursor-pointer">
// --- DOWNLOAD SPEED DROPDOWN ---
<details class="dropdown dropdown-top" node_ref=down_details_ref>
<summary class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
<details class="group relative" node_ref=down_details_ref>
<summary class="flex items-center gap-2 cursor-pointer hover:text-foreground transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
<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="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
</svg>
@@ -110,37 +93,44 @@ pub fn StatusBar() -> impl IntoView {
</Show>
</summary>
<ul class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300">
{
limits.clone().into_iter().map(|(val, label)| {
let is_active = move || {
let current = stats.get().down_limit.unwrap_or(0);
(current - val).abs() < 1024
};
view! {
<li>
<button
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
on:click=move |_| {
set_limit("down", val);
close_details(down_details_ref);
}
>
{label}
<Show when=is_active fallback=|| ()>
<span>""</span>
</Show>
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
<div class="absolute bottom-full left-0 mb-2 z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md hidden group-open:block animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2">
<ul class="w-full">
{
limits.clone().into_iter().map(|(val, label)| {
let is_active = move || {
let current = stats.get().down_limit.unwrap_or(0);
(current - val).abs() < 1024
};
view! {
<li>
<button
class=move || {
let base = "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent hover:text-accent-foreground";
if is_active() { format!("{} bg-accent text-accent-foreground font-medium", base) } else { base.to_string() }
}
on:click=move |_| {
set_limit("down", val);
close_details(down_details_ref);
}
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Show when=is_active fallback=|| ()>
<span>""</span>
</Show>
</span>
{label}
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</div>
</details>
// --- UPLOAD SPEED DROPDOWN ---
<details class="dropdown dropdown-top" node_ref=up_details_ref>
<summary class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
<details class="group relative" node_ref=up_details_ref>
<summary class="flex items-center gap-2 cursor-pointer hover:text-foreground transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
<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="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
</svg>
@@ -152,114 +142,51 @@ pub fn StatusBar() -> impl IntoView {
</Show>
</summary>
<ul class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300">
{
limits.clone().into_iter().map(|(val, label)| {
let is_active = move || {
let current = stats.get().up_limit.unwrap_or(0);
(current - val).abs() < 1024
};
view! {
<li>
<button
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
on:click=move |_| {
set_limit("up", val);
close_details(up_details_ref);
}
>
{label}
<Show when=is_active fallback=|| ()>
<span>""</span>
</Show>
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</details>
<div class="ml-auto flex items-center gap-4">
<details class="dropdown dropdown-top dropdown-end" node_ref=theme_details_ref>
<summary class="btn btn-ghost btn-xs btn-square cursor-pointer outline-none list-none [&::-webkit-details-marker]:hidden">
<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.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
</svg>
</summary>
<ul class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-52 mb-2 border border-base-300 max-h-96 overflow-y-auto">
<div class="absolute bottom-full left-0 mb-2 z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md hidden group-open:block animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2">
<ul class="w-full">
{
let themes = vec![
"light", "dark", "dim", "nord", "cupcake", "dracula", "cyberpunk", "emerald", "sunset", "abyss"
];
themes.into_iter().map(|theme| {
let theme_name = theme.to_string();
let theme_name_for_class = theme_name.clone();
let theme_name_for_onclick = theme_name.clone();
limits.clone().into_iter().map(|(val, label)| {
let is_active = move || {
let current = stats.get().up_limit.unwrap_or(0);
(current - val).abs() < 1024
};
view! {
<li>
<button
class=move || if current_theme.get() == theme_name_for_class { "bg-primary/10 text-primary font-bold text-xs capitalize" } else { "text-xs capitalize" }
class=move || {
let base = "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent hover:text-accent-foreground";
if is_active() { format!("{} bg-accent text-accent-foreground font-medium", base) } else { base.to_string() }
}
on:click=move |_| {
set_current_theme.set(theme_name_for_onclick.clone());
close_details(theme_details_ref);
set_limit("up", val);
close_details(up_details_ref);
}
>
{theme_name}
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Show when=is_active fallback=|| ()>
<span>""</span>
</Show>
</span>
{label}
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</details>
</div>
</details>
<div class="ml-auto flex items-center gap-4">
<button
class="btn btn-ghost btn-xs btn-square"
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 hover:bg-accent hover:text-accent-foreground h-7 w-7"
title="Settings & Notification Permissions"
on:click=move |_| {
// Request push notification permission when settings button is clicked
// Request push notification permission
leptos::task::spawn_local(async {
log::info!("Settings button clicked - requesting push notification permission");
// Check current permission state before requesting
let window = web_sys::window().expect("window should exist");
let _current_perm = js_sys::Reflect::get(&window, &"Notification".into())
.ok()
.and_then(|n| js_sys::Reflect::get(&n, &"permission".into()).ok())
.and_then(|p| p.as_string())
.unwrap_or_default();
// ... existing logic ...
crate::store::subscribe_to_push_notifications().await;
// Check permission after request
let new_perm = js_sys::Reflect::get(&window, &"Notification".into())
.ok()
.and_then(|n| js_sys::Reflect::get(&n, &"permission".into()).ok())
.and_then(|p| p.as_string())
.unwrap_or_default();
if let Some(store) = use_context::<crate::store::TorrentStore>() {
if new_perm == "granted" {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Success,
"Bildirimler etkinleştirildi! Torrent tamamlandığında bildirim alacaksınız.".to_string(),
);
} else if new_perm == "denied" {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Error,
"Bildirim izni reddedildi. Tarayıcı ayarlarından izin verebilirsiniz.".to_string(),
);
} else {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Warning,
"Bildirim izni verilemedi. Açılan izin penceresinde 'İzin Ver' seçeneğini seçin.".to_string(),
);
}
}
// ... existing logic ...
});
}
>

View File

@@ -1,59 +1,61 @@
use leptos::prelude::*;
use leptos_shadcn_input::Input;
use leptos_shadcn_button::{Button, ButtonVariant, ButtonSize};
use crate::components::torrent::add_torrent::AddTorrentDialog;
#[component]
pub fn Toolbar() -> impl IntoView {
let show_add_modal = signal(false);
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! {
<div class="navbar min-h-14 h-auto bg-base-100 p-0" style="padding-top: env(safe-area-inset-top);">
<div class="navbar-start gap-4 px-4">
<label for="my-drawer" class="btn btn-square btn-ghost lg:hidden drawer-button">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</label>
<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);">
// Sol kısım: Menü butonu + Add Torrent
<div class="flex items-center gap-3">
// 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>
</Button>
<div class="flex items-center gap-3">
<button
class="btn btn-primary btn-sm md:btn-md gap-2 shadow-md hover:shadow-primary/20 transition-all"
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">
<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>
<Button
class="gap-2 shadow"
on_click=Callback::new(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">
<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>
<div class="navbar-center hidden md:flex">
<div class="join shadow-sm border border-base-200">
<div class="relative">
<input
type="text"
placeholder="Search..."
class="input input-sm input-bordered join-item w-full max-w-xs focus:outline-none"
prop:value=move || store.search_query.get()
on:input=move |ev| store.search_query.set(event_target_value(&ev))
// 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>
<Input
input_type="search"
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"
/>
<Show when=move || !store.search_query.get().is_empty()>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
on:click=move |_| store.search_query.set(String::new())
>
"×"
</button>
</Show>
</div>
</div>
</div>
<div class="navbar-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>
<Show when=move || show_add_modal.0.get()>
<AddTorrentDialog on_close=Callback::new(move |()| show_add_modal.1.set(false)) />
</Show>
</div>
}
}

View File

@@ -1,5 +1,4 @@
pub mod context_menu;
pub mod layout;
pub mod toast;
pub mod torrent;
pub mod auth;

View File

@@ -1,83 +0,0 @@
use leptos::prelude::*;
use shared::NotificationLevel;
// ============================================================================
// Toast Components - DaisyUI Alert Style
// ============================================================================
/// Returns the DaisyUI alert class for the notification level
fn get_alert_class(level: &NotificationLevel) -> &'static str {
match level {
NotificationLevel::Info => "alert alert-info",
NotificationLevel::Success => "alert alert-success",
NotificationLevel::Warning => "alert alert-warning",
NotificationLevel::Error => "alert alert-error",
}
}
/// Individual toast item component
#[component]
fn ToastItem(
level: NotificationLevel,
message: String,
) -> impl IntoView {
let alert_class = get_alert_class(&level);
// DaisyUI SVG icons
let icon_svg = match level {
NotificationLevel::Info => view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
}.into_any(),
NotificationLevel::Success => view! {
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
}.into_any(),
NotificationLevel::Warning => view! {
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
}.into_any(),
NotificationLevel::Error => view! {
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
}.into_any(),
};
view! {
<div class=alert_class>
{icon_svg}
<span>{message}</span>
</div>
}
}
/// Main toast container - renders all active notifications
#[component]
pub fn ToastContainer() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("TorrentStore not provided");
let notifications = store.notifications;
view! {
<div
class="toast toast-end toast-bottom"
style="position: fixed; bottom: 20px; right: 20px; z-index: 99999;"
>
<For
each=move || notifications.get()
key=|item| item.id
children=move |item| {
view! {
<ToastItem
level=item.notification.level
message=item.notification.message
/>
}
}
/>
</div>
}
}

View File

@@ -1,6 +1,8 @@
use leptos::prelude::*;
use leptos::html;
use leptos::task::spawn_local;
use leptos_shadcn_input::Input;
use leptos_shadcn_button::{Button, ButtonVariant};
use leptos_shadcn_alert::{Alert, AlertDescription, AlertVariant};
use crate::store::TorrentStore;
use crate::api;
@@ -8,20 +10,12 @@ use crate::api;
pub fn AddTorrentDialog(
on_close: Callback<()>,
) -> impl IntoView {
let store = use_context::<TorrentStore>().expect("TorrentStore not provided");
let notifications = store.notifications;
let _store = use_context::<TorrentStore>().expect("TorrentStore not provided");
let dialog_ref = NodeRef::<html::Dialog>::new();
let uri = signal(String::new());
let is_loading = signal(false);
let error_msg = signal(Option::<String>::None);
Effect::new(move |_| {
if let Some(dialog) = dialog_ref.get() {
let _ = dialog.show_modal();
}
});
let handle_submit = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
let uri_val = uri.0.get();
@@ -39,14 +33,7 @@ pub fn AddTorrentDialog(
match api::torrent::add(&uri_val).await {
Ok(_) => {
log::info!("Torrent added successfully");
crate::store::show_toast_with_signal(
notifications,
shared::NotificationLevel::Success,
"Torrent başarıyla eklendi"
);
if let Some(dialog) = dialog_ref.get() {
dialog.close();
}
crate::store::toast_success("Torrent başarıyla eklendi");
on_close.run(());
}
Err(e) => {
@@ -58,51 +45,76 @@ pub fn AddTorrentDialog(
});
};
let handle_cancel = move |_| {
if let Some(dialog) = dialog_ref.get() {
dialog.close();
let handle_backdrop = {
let on_close = on_close.clone();
move |e: web_sys::MouseEvent| {
e.stop_propagation();
on_close.run(());
}
on_close.run(());
};
view! {
<dialog node_ref=dialog_ref class="modal modal-bottom sm:modal-middle">
<div class="modal-box">
<h3 class="font-bold text-lg">"Add Torrent"</h3>
<p class="py-4 text-sm opacity-70">"Enter a Magnet link or a .torrent file URL."</p>
<form on:submit=handle_submit>
<div class="form-control w-full">
<input
type="text"
placeholder="magnet:?xt=urn:btih:..."
class="input input-bordered w-full"
prop:value=move || uri.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">
<button type="button" class="btn btn-ghost" on:click=handle_cancel>"Cancel"</button>
<button type="submit" class="btn btn-primary" disabled=move || is_loading.0.get()>
{move || if is_loading.0.get() {
leptos::either::Either::Left(view! { <span class="loading loading-spinner"></span> "Adding..." })
} else {
leptos::either::Either::Right(view! { "Add" })
}}
</button>
</div>
</form>
{move || error_msg.0.get().map(|msg| view! {
<div class="text-error text-sm mt-2">{msg}</div>
})}
// Backdrop overlay
<div
class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
on:click=handle_backdrop
/>
// Dialog panel
<div class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-lg sm:max-w-[425px]">
// Header
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
<h2 class="text-lg font-semibold leading-none tracking-tight">"Add Torrent"</h2>
<p class="text-sm text-muted-foreground">"Enter a Magnet link or a .torrent file URL."</p>
</div>
<form method="dialog" class="modal-backdrop">
<button on:click=handle_cancel>"close"</button>
<form on:submit=handle_submit class="space-y-4">
<Input
input_type="text"
placeholder="magnet:?xt=urn:btih:..."
value=MaybeProp::derive(move || Some(uri.0.get()))
on_change=Callback::new(move |val: String| uri.1.set(val))
disabled=Signal::derive(move || is_loading.0.get())
/>
{move || error_msg.0.get().map(|msg| view! {
<Alert variant=AlertVariant::Destructive>
<AlertDescription>{msg}</AlertDescription>
</Alert>
})}
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
<Button
variant=ButtonVariant::Ghost
on_click=Callback::new(move |()| {
on_close.run(());
})
>
"Cancel"
</Button>
<Button disabled=Signal::derive(move || is_loading.0.get())>
{move || if is_loading.0.get() {
leptos::either::Either::Left(view! {
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
"Adding..."
})
} else {
leptos::either::Either::Right(view! { "Add" })
}}
</Button>
</div>
</form>
</dialog>
// Close button (X)
<button
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none"
on:click=move |_| on_close.run(())
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
<span class="sr-only">"Close"</span>
</button>
</div>
}
}

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 add_torrent;
pub mod detail;

View File

@@ -1,10 +1,10 @@
use leptos::prelude::*;
use leptos::html;
use leptos::task::spawn_local;
use leptos_use::use_timeout_fn;
use crate::store::{get_action_messages, show_toast_with_signal};
use crate::store::{get_action_messages, show_toast};
use crate::api;
use shared::NotificationLevel;
use crate::components::context_menu::TorrentContextMenu;
use leptos_shadcn_card::{Card, CardHeader, CardTitle, CardContent};
fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
@@ -50,15 +50,13 @@ pub fn TorrentTable() -> impl IntoView {
let sort_col = signal(SortColumn::AddedDate);
let sort_dir = signal(SortDirection::Descending);
let filtered_hashes = move || {
let filtered_hashes = Memo::new(move |_| {
let torrents_map = store.torrents.get();
log::debug!("TorrentTable: store.torrents has {} entries", torrents_map.len());
let filter = store.filter.get();
let search = store.search_query.get();
let search_lower = search.to_lowercase();
let mut torrents: Vec<&shared::Torrent> = torrents_map.values().filter(|t| {
let mut torrents: Vec<shared::Torrent> = torrents_map.values().filter(|t| {
let matches_filter = match filter {
crate::store::FilterStatus::All => true,
crate::store::FilterStatus::Downloading => t.status == shared::TorrentStatus::Downloading,
@@ -70,9 +68,7 @@ pub fn TorrentTable() -> impl IntoView {
};
let matches_search = if search_lower.is_empty() { true } else { t.name.to_lowercase().contains(&search_lower) };
matches_filter && matches_search
}).collect();
log::debug!("TorrentTable: {} torrents after filtering", torrents.len());
}).cloned().collect();
torrents.sort_by(|a, b| {
let col = sort_col.0.get();
@@ -94,7 +90,7 @@ pub fn TorrentTable() -> impl IntoView {
if dir == SortDirection::Descending { cmp.reverse() } else { cmp }
});
torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
};
});
let handle_sort = move |col: SortColumn| {
if sort_col.0.get() == col {
@@ -107,8 +103,6 @@ pub fn TorrentTable() -> impl IntoView {
}
};
let sort_details_ref = NodeRef::<html::Details>::new();
let sort_arrow = move |col: SortColumn| {
if sort_col.0.get() == col {
match sort_dir.0.get() {
@@ -118,22 +112,10 @@ pub fn TorrentTable() -> impl IntoView {
} 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 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 on_action = Callback::new(move |(action, hash): (String, String)| {
let (success_msg_str, error_msg_str): (&'static str, &'static str) = get_action_messages(&action);
let success_msg = success_msg_str.to_string();
let error_msg = error_msg_str.to_string();
let notifications = store.notifications;
spawn_local(async move {
let result = match action.as_str() {
"delete" => api::torrent::delete(&hash).await,
@@ -143,91 +125,78 @@ pub fn TorrentTable() -> impl IntoView {
_ => api::torrent::action(&hash, &action).await,
};
match result {
Ok(_) => show_toast_with_signal(notifications, NotificationLevel::Success, success_msg),
Err(e) => show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: {:?}", error_msg, e)),
Ok(_) => show_toast(NotificationLevel::Success, success_msg),
Err(e) => show_toast(NotificationLevel::Error, format!("{}: {:?}", error_msg, e)),
}
});
};
});
view! {
<div class="overflow-x-auto h-full bg-base-100 relative">
<div class="hidden md:block h-full overflow-x-auto">
<table class="table table-sm table-pin-rows w-full max-w-full whitespace-nowrap">
<thead>
<tr class="text-xs uppercase text-base-content/60 border-b border-base-200">
<th class="cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
<div class="flex items-center">"Name" {move || sort_arrow(SortColumn::Name)}</div>
</th>
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::Size)>
<div class="flex items-center">"Size" {move || sort_arrow(SortColumn::Size)}</div>
</th>
<th class="w-48 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::Progress)>
<div class="flex items-center">"Progress" {move || sort_arrow(SortColumn::Progress)}</div>
</th>
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::Status)>
<div class="flex items-center">"Status" {move || sort_arrow(SortColumn::Status)}</div>
</th>
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
<div class="flex items-center">"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}</div>
</th>
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
<div class="flex items-center">"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}</div>
</th>
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::ETA)>
<div class="flex items-center">"ETA" {move || sort_arrow(SortColumn::ETA)}</div>
</th>
<th class="w-32 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::AddedDate)>
<div class="flex items-center">"Date" {move || sort_arrow(SortColumn::AddedDate)}</div>
</th>
</tr>
</thead>
<tbody>
<For each=move || filtered_hashes() key=|hash| hash.clone() children={
let handle_context_menu = handle_context_menu.clone();
move |hash| view! { <TorrentRow hash=hash.clone() selected_hash=selected_hash.0 set_selected_hash=selected_hash.1 on_context_menu=handle_context_menu.clone() /> }
} />
</tbody>
</table>
</div>
<div class="md:hidden flex flex-col h-full bg-base-200 relative cursor-pointer">
<div class="px-3 py-2 border-b border-base-200 flex justify-between items-center bg-base-100/95 backdrop-blur z-10 shrink-0 cursor-default">
<span class="text-xs font-bold opacity-50 uppercase tracking-wider">"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">
<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>
<ul class="dropdown-content z-[100] menu p-2 shadow bg-base-100 rounded-box w-48 mt-1 border border-base-200 text-xs cursor-default">
<li class="menu-title px-2 py-1 opacity-50 text-[10px] uppercase font-bold">"Sort By"</li>
{
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-primary/10 text-primary font-bold flex justify-between" } else { "flex justify-between" } 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="opacity-70 text-[10px]">{move || match sort_dir.0.get() { SortDirection::Ascending => "", SortDirection::Descending => "" }}</span></Show>
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</details>
<div class="h-full bg-background relative flex flex-col overflow-hidden">
// --- DESKTOP VIEW ---
<div class="hidden md:flex flex-col h-full overflow-hidden">
// Header
<div class="flex items-center text-xs uppercase text-muted-foreground border-b border-border bg-muted/50 h-9 shrink-0 px-2 font-medium">
<div class="flex-1 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Name)>
"Name" {move || sort_arrow(SortColumn::Name)}
</div>
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Size)>
"Size" {move || sort_arrow(SortColumn::Size)}
</div>
<div class="w-48 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Progress)>
"Progress" {move || sort_arrow(SortColumn::Progress)}
</div>
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Status)>
"Status" {move || sort_arrow(SortColumn::Status)}
</div>
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}
</div>
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}
</div>
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::ETA)>
"ETA" {move || sort_arrow(SortColumn::ETA)}
</div>
<div class="w-32 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::AddedDate)>
"Date" {move || sort_arrow(SortColumn::AddedDate)}
</div>
</div>
<div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3 cursor-pointer">
<For each=move || filtered_hashes() key=|hash| hash.clone() children={
let handle_context_menu = handle_context_menu.clone();
move |hash| view! { <TorrentCard hash=hash.clone() selected_hash=selected_hash.0 set_selected_hash=selected_hash.1 set_menu_position=menu_position.1 set_menu_visible=menu_visible.1 on_context_menu=handle_context_menu.clone() /> }
// Regular List
<div class="flex-1 overflow-y-auto min-h-0">
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
let on_action = on_action.clone();
move |hash| {
let h = hash.clone();
view! {
<TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
<TorrentRow hash=hash.clone() />
</TorrentContextMenu>
}
}
} />
</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>
// --- MOBILE VIEW ---
<div class="md:hidden flex flex-col h-full bg-muted/10 relative overflow-hidden">
<div class="flex-1 overflow-y-auto p-3 min-h-0">
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
let on_action = on_action.clone();
move |hash| {
let h = hash.clone();
view! {
<div class="pb-3">
<TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
<TorrentCard hash=hash.clone() />
</TorrentContextMenu>
</div>
}
}
} />
</div>
</div>
</div>
}
}
@@ -235,60 +204,50 @@ pub fn TorrentTable() -> impl IntoView {
#[component]
fn TorrentRow(
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 {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let h = hash.clone();
let torrent = Memo::new(move |_| store.torrents.with(|map| map.get(&h).cloned()));
let stored_hash = StoredValue::new(hash.clone());
view! {
<Show when=move || torrent.get().is_some() fallback=|| ()>
{
let on_context_menu = on_context_menu.clone();
let hash = hash.clone();
move || {
let t = torrent.get().unwrap();
let t_hash = hash.clone();
let t_name = t.name.clone();
let status_class = match t.status { shared::TorrentStatus::Seeding => "text-success", shared::TorrentStatus::Downloading => "text-primary", shared::TorrentStatus::Paused => "text-warning", shared::TorrentStatus::Error => "text-error", _ => "text-base-content/50" };
let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" };
let selected_hash_clone = selected_hash.clone();
let t_hash_row = t_hash.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" };
view! {
<tr
<div
class=move || {
let base = "hover border-b border-base-200 select-none";
if selected_hash_clone.get() == Some(t_hash_row.clone()) { format!("{} bg-primary/10", base) } else { base.to_string() }
}
on:contextmenu={
let t_hash = t_hash.clone();
let on_context_menu = on_context_menu.clone();
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()))
let selected = store.selected_torrent.get();
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
if is_selected {
"flex items-center text-sm bg-primary/10 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full"
} else {
"flex items-center text-sm hover:bg-muted/50 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full"
}
}
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
>
<td class="font-medium truncate max-w-xs" title=t_name.clone()>{t_name.clone()}</td>
<td class="opacity-80 font-mono text-[11px]">{format_bytes(t.size)}</td>
<td>
<div class="flex-1 min-w-0 px-2 font-medium truncate" title=t_name.clone()>{t_name.clone()}</div>
<div class="w-24 px-2 font-mono text-xs text-muted-foreground">{format_bytes(t.size)}</div>
<div class="w-48 px-2">
<div class="flex items-center gap-2">
<progress class={format!("progress w-24 {}", progress_class)} value={t.percent_complete} max="100"></progress>
<span class="text-[10px] opacity-70">{format!("{:.1}%", t.percent_complete)}</span>
<div class="h-2 w-full bg-secondary rounded-full overflow-hidden">
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
</div>
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>
</div>
</td>
<td class={format!("text-[11px] font-medium {}", status_class)}>{format!("{:?}", t.status)}</td>
<td class="text-right font-mono text-[11px] opacity-80 text-success">{format_speed(t.down_rate)}</td>
<td class="text-right font-mono text-[11px] opacity-80 text-primary">{format_speed(t.up_rate)}</td>
<td class="text-right font-mono text-[11px] opacity-80">{format_duration(t.eta)}</td>
<td class="text-right font-mono text-[11px] opacity-80 whitespace-nowrap">{format_date(t.added_date)}</td>
</tr>
</div>
<div class={format!("w-24 px-2 text-xs font-medium {}", status_color)}>{format!("{:?}", t.status)}</div>
<div class="w-24 px-2 text-right font-mono text-xs text-green-600 dark:text-green-500">{format_speed(t.down_rate)}</div>
<div class="w-24 px-2 text-right font-mono text-xs text-blue-600 dark:text-blue-500">{format_speed(t.up_rate)}</div>
<div class="w-24 px-2 text-right font-mono text-xs text-muted-foreground">{format_duration(t.eta)}</div>
<div class="w-32 px-2 text-right font-mono text-xs text-muted-foreground">{format_date(t.added_date)}</div>
</div>
}
}
}
@@ -299,88 +258,63 @@ fn TorrentRow(
#[component]
fn TorrentCard(
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 {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let h = hash.clone();
let torrent = Memo::new(move |_| store.torrents.with(|map| map.get(&h).cloned()));
let stored_hash = StoredValue::new(hash.clone());
view! {
<Show when=move || torrent.get().is_some() fallback=|| ()>
{
let hash = hash.clone();
let on_context_menu = on_context_menu.clone();
move || {
let t = torrent.get().unwrap();
let t_hash = hash.clone();
let t_name = t.name.clone();
let status_badge_class = match t.status { shared::TorrentStatus::Seeding => "badge-success badge-soft", shared::TorrentStatus::Downloading => "badge-primary badge-soft", shared::TorrentStatus::Paused => "badge-warning badge-soft", shared::TorrentStatus::Error => "badge-error badge-soft", _ => "badge-ghost" };
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 leptos_use::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();
let status_badge_class = match t.status { shared::TorrentStatus::Seeding => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border-green-200 dark:border-green-800", shared::TorrentStatus::Downloading => "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800", shared::TorrentStatus::Paused => "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800", shared::TorrentStatus::Error => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800", _ => "bg-muted text-muted-foreground" };
view! {
<div
class=move || {
let base = "card card-compact bg-base-100 shadow-sm border border-base-200 select-none cursor-pointer";
if selected_hash_clone.get() == Some(t_hash_card.clone()) { format!("{} ring-2 ring-primary ring-inset", base) } else { base.to_string() }
}
on:contextmenu={
let t_hash = t_hash.clone();
let on_context_menu = on_context_menu.clone();
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())); }
let selected = store.selected_torrent.get();
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
if is_selected {
"ring-2 ring-primary rounded-lg transition-all"
} else {
"transition-all"
}
}
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
>
<div class="card-body gap-3">
<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">
<h3 class="font-medium text-sm line-clamp-2 leading-tight">{t_name.clone()}</h3>
<div class={format!("badge badge-xs text-[10px] whitespace-nowrap {}", status_badge_class)}>{format!("{:?}", t.status)}</div>
<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>
</CardHeader>
<CardContent class="p-3 pt-2 gap-3 flex flex-col">
<div class="flex flex-col gap-1">
<div class="flex justify-between text-[10px] opacity-70">
<div class="flex justify-between text-[10px] text-muted-foreground">
<span>{format_bytes(t.size)}</span>
<span>{format!("{:.1}%", t.percent_complete)}</span>
</div>
<progress class="progress w-full h-1.5" value={t.percent_complete} max="100"></progress>
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden">
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
</div>
</div>
<div class="grid grid-cols-4 gap-2 text-[10px] font-mono opacity-80 pt-1 border-t border-base-200/50">
<div class="flex flex-col text-success"><span>"DL"</span><span>{format_speed(t.down_rate)}</span></div>
<div class="flex flex-col text-primary"><span>"UP"</span><span>{format_speed(t.up_rate)}</span></div>
<div class="grid grid-cols-4 gap-2 text-[10px] font-mono text-muted-foreground pt-1 border-t border-border/50">
<div class="flex flex-col text-blue-600 dark:text-blue-500"><span>"DL"</span><span>{format_speed(t.down_rate)}</span></div>
<div class="flex flex-col text-green-600 dark:text-green-500"><span>"UP"</span><span>{format_speed(t.up_rate)}</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>
</div>
</CardContent>
</Card>
</div>
}
}
}
</Show>
}
}
}

View File

@@ -1,56 +1,27 @@
use futures::StreamExt;
use gloo_net::eventsource::futures::EventSource;
use leptos::prelude::*;
use shared::{AppEvent, GlobalStats, NotificationLevel, SystemNotification, Torrent};
use leptos::task::spawn_local;
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
use std::collections::HashMap;
use serde::{Serialize, Deserialize};
#[derive(Clone, Debug, PartialEq)]
pub struct NotificationItem {
pub id: u64,
pub notification: SystemNotification,
}
// ============================================================================
// Toast Helper Functions
// ============================================================================
pub fn show_toast_with_signal(
notifications: RwSignal<Vec<NotificationItem>>,
level: NotificationLevel,
message: impl Into<String>,
) {
let id = js_sys::Date::now() as u64;
let notification = SystemNotification {
level,
message: message.into(),
};
let item = NotificationItem { id, notification };
notifications.update(|list| list.push(item));
// Auto-remove after 5 seconds
leptos::prelude::set_timeout(
move || {
notifications.update(|list| list.retain(|i| i.id != id));
},
std::time::Duration::from_secs(5),
);
}
use struct_patch::traits::Patch;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
if let Some(store) = use_context::<TorrentStore>() {
show_toast_with_signal(store.notifications, level, message);
let msg = message.into();
match level {
NotificationLevel::Info => { leptos_shadcn_toast::toast::info(&msg).show(); },
NotificationLevel::Success => { leptos_shadcn_toast::toast::success(&msg).show(); },
NotificationLevel::Warning => { leptos_shadcn_toast::toast::warning(&msg).show(); },
NotificationLevel::Error => { leptos_shadcn_toast::toast::error(&msg).show(); },
}
}
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); }
pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); }
// ============================================================================
// Action Message Mapping
// ============================================================================
pub fn get_action_messages(action: &str) -> (&'static str, &'static str) {
match action {
"start" => ("Torrent başlatıldı", "Torrent başlatılamadı"),
@@ -63,22 +34,6 @@ pub fn get_action_messages(action: &str) -> (&'static str, &'static str) {
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PushSubscriptionData {
pub endpoint: String,
pub keys: PushKeys,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PushKeys {
pub p256dh: String,
pub auth: String,
}
// ============================================================================
// Store Definition
// ============================================================================
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FilterStatus {
All, Downloading, Seeding, Completed, Paused, Inactive, Active, Error,
@@ -90,8 +45,8 @@ pub struct TorrentStore {
pub filter: RwSignal<FilterStatus>,
pub search_query: RwSignal<String>,
pub global_stats: RwSignal<GlobalStats>,
pub notifications: RwSignal<Vec<NotificationItem>>,
pub user: RwSignal<Option<String>>,
pub selected_torrent: RwSignal<Option<String>>,
}
pub fn provide_torrent_store() {
@@ -99,119 +54,107 @@ pub fn provide_torrent_store() {
let filter = RwSignal::new(FilterStatus::All);
let search_query = RwSignal::new(String::new());
let global_stats = RwSignal::new(GlobalStats::default());
let notifications = RwSignal::new(Vec::<NotificationItem>::new());
let user = RwSignal::new(Option::<String>::None);
let selected_torrent = RwSignal::new(Option::<String>::None);
let show_browser_notification = crate::utils::notification::use_app_notification();
let store = TorrentStore { torrents, filter, search_query, global_stats, notifications, user };
let store = TorrentStore { torrents, filter, search_query, global_stats, user, selected_torrent };
provide_context(store);
let notifications_for_effect = notifications;
let global_stats_for_effect = global_stats;
let torrents_for_effect = torrents;
let global_stats_for_sse = global_stats;
let torrents_for_sse = torrents;
let show_browser_notification = show_browser_notification.clone();
Effect::new(move |_| {
let user_val = user.get();
log::debug!("SSE Effect: user = {:?}", user_val);
if user_val.is_none() {
log::debug!("SSE Effect: user is None, skipping connection");
return;
}
spawn_local(async move {
let mut backoff_ms: u32 = 1000;
let mut was_connected = false;
let mut disconnect_notified = false;
let notifications = notifications_for_effect;
let global_stats = global_stats_for_effect;
let torrents = torrents_for_effect;
let show_browser_notification = show_browser_notification.clone();
loop {
log::info!("SSE: Starting connection (user logged in)");
leptos::task::spawn_local(async move {
let mut backoff_ms: u32 = 1000;
let mut was_connected = false;
let mut disconnect_notified = false;
loop {
log::debug!("SSE: Creating EventSource...");
let es_result = EventSource::new("/api/events");
match es_result {
Ok(mut es) => {
log::debug!("SSE: EventSource created, subscribing to message channel...");
if let Ok(mut stream) = es.subscribe("message") {
log::debug!("SSE: Subscribed to message channel");
let mut got_first_message = false;
while let Some(Ok((_, msg))) = stream.next().await {
log::debug!("SSE: Received message: {:?}", msg.data());
if !got_first_message {
got_first_message = true;
backoff_ms = 1000;
if was_connected && disconnect_notified {
show_toast_with_signal(notifications, NotificationLevel::Success, "Sunucu bağlantısı yeniden kuruldu");
disconnect_notified = false;
}
was_connected = true;
log::debug!("SSE: Creating EventSource...");
let es_result = EventSource::new("/api/events");
match es_result {
Ok(mut es) => {
log::debug!("SSE: EventSource created, subscribing...");
if let Ok(mut stream) = es.subscribe("message") {
log::debug!("SSE: Subscribed to message channel");
let mut got_first_message = false;
while let Some(Ok((_, msg))) = stream.next().await {
log::debug!("SSE: Received message");
if !got_first_message {
got_first_message = true;
backoff_ms = 1000;
if was_connected && disconnect_notified {
show_toast(NotificationLevel::Success, "Sunucu bağlantısı yeniden kuruldu");
disconnect_notified = false;
}
was_connected = true;
}
if let Some(data_str) = msg.data().as_string() {
log::debug!("SSE: Parsing JSON: {}", data_str);
if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) {
match event {
AppEvent::FullList { torrents: list, .. } => {
log::info!("SSE: Received FullList with {} torrents", list.len());
torrents.update(|map| {
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
map.retain(|hash, _| new_hashes.contains(hash));
for new_torrent in list {
map.insert(new_torrent.hash.clone(), new_torrent);
if let Some(data_str) = msg.data().as_string() {
// Decode Base64
match BASE64.decode(&data_str) {
Ok(bytes) => {
// Deserialize MessagePack
match rmp_serde::from_slice::<AppEvent>(&bytes) {
Ok(event) => {
match event {
AppEvent::FullList(list, _) => {
log::info!("SSE: Received FullList with {} torrents", list.len());
torrents_for_sse.update(|map| {
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
map.retain(|hash, _| new_hashes.contains(hash));
for new_torrent in list {
map.insert(new_torrent.hash.clone(), new_torrent);
}
});
log::debug!("SSE: torrents map now has {} entries", torrents_for_sse.with(|m| m.len()));
}
});
log::debug!("SSE: torrents map now has {} entries", torrents.with(|m| m.len()));
}
AppEvent::Update(update) => {
torrents.update(|map| {
if let Some(t) = map.get_mut(&update.hash) {
if let Some(v) = update.name { t.name = v; }
if let Some(v) = update.size { t.size = v; }
if let Some(v) = update.down_rate { t.down_rate = v; }
if let Some(v) = update.up_rate { t.up_rate = v; }
if let Some(v) = update.percent_complete { t.percent_complete = v; }
if let Some(v) = update.completed { t.completed = v; }
if let Some(v) = update.eta { t.eta = v; }
if let Some(v) = update.status { t.status = v; }
if let Some(v) = update.error_message { t.error_message = v; }
if let Some(v) = update.label { t.label = Some(v); }
AppEvent::Update(patch) => {
let hash_opt = patch.hash.clone();
if let Some(hash) = hash_opt {
torrents_for_sse.update(|map| {
if let Some(t) = map.get_mut(&hash) {
t.apply(patch);
}
});
}
}
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
AppEvent::Notification(n) => {
show_toast(n.level.clone(), n.message.clone());
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
show_browser_notification("VibeTorrent", &n.message);
}
}
});
}
AppEvent::Stats(stats) => { global_stats.set(stats); }
AppEvent::Notification(n) => {
show_toast_with_signal(notifications, n.level.clone(), n.message.clone());
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
show_browser_notification("VibeTorrent", &n.message);
}
}
Err(e) => log::error!("SSE: Failed to deserialize MessagePack: {}", e),
}
}
Err(e) => log::error!("SSE: Failed to decode Base64: {}", e),
}
}
if was_connected && !disconnect_notified {
show_toast_with_signal(notifications, NotificationLevel::Warning, "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...");
disconnect_notified = true;
}
}
}
Err(_) => {
if was_connected && !disconnect_notified {
show_toast_with_signal(notifications, NotificationLevel::Warning, "Sunucu bağlantısı kurulamıyor...");
show_toast(NotificationLevel::Warning, "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...");
disconnect_notified = true;
}
}
}
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
backoff_ms = std::cmp::min(backoff_ms * 2, 30000);
Err(_) => {
if was_connected && !disconnect_notified {
show_toast(NotificationLevel::Warning, "Sunucu bağlantısı kurulamıyor...");
disconnect_notified = true;
}
}
}
});
log::debug!("SSE: Reconnecting in {}ms...", backoff_ms);
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
backoff_ms = std::cmp::min(backoff_ms * 2, 30000);
}
});
}

View File

@@ -1,10 +1,12 @@
use wasm_bindgen::prelude::*;
use web_sys::{Notification, NotificationOptions};
use leptos::prelude::*;
use leptos_use::{use_web_notification, UseWebNotificationReturn, NotificationPermission};
/// Request browser notification permission from user
pub async fn request_notification_permission() -> bool {
if !is_notification_supported() {
return false;
}
if let Ok(promise) = Notification::request_permission() {
if let Ok(result) = wasm_bindgen_futures::JsFuture::from(promise).await {
return result.as_string().unwrap_or_default() == "granted";
@@ -21,6 +23,9 @@ pub fn is_notification_supported() -> bool {
/// Get current notification permission status
pub fn get_notification_permission() -> String {
if !is_notification_supported() {
return "denied".to_string();
}
match Notification::permission() {
web_sys::NotificationPermission::Granted => "granted".to_string(),
web_sys::NotificationPermission::Denied => "denied".to_string(),
@@ -32,8 +37,6 @@ pub fn get_notification_permission() -> String {
/// Hook for using browser notifications within Leptos components or effects.
/// This uses leptos-use for reactive permission tracking.
pub fn use_app_notification() -> impl Fn(&str, &str) + Clone {
let UseWebNotificationReturn { permission, .. } = use_web_notification();
move |title: &str, body: &str| {
// Check user preference from localStorage
let window = web_sys::window().expect("no global window");
@@ -42,8 +45,8 @@ pub fn use_app_notification() -> impl Fn(&str, &str) + Clone {
.and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten())
.unwrap_or_else(|| "true".to_string());
// Use the reactive permission signal from leptos-use
if enabled == "true" && permission.get() == NotificationPermission::Granted {
// Check platform support and permission
if enabled == "true" && is_notification_supported() && get_notification_permission() == "granted" {
show_browser_notification(title, body);
}
}

View File

@@ -1,6 +1,20 @@
const path = require("path");
const os = require("os");
// Cargo registry'deki leptos-shadcn crate'lerini Tailwind'e taratmak için
const cargoRegistry = path.join(
os.homedir(),
".cargo/registry/src/*/leptos-shadcn-*/src/**/*.rs"
);
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.{rs,html}"],
darkMode: "class",
content: [
"./index.html",
"./src/**/*.{rs,html}",
cargoRegistry,
],
theme: {
extend: {
colors: {

View File

@@ -0,0 +1,30 @@
[package]
edition = "2018"
name = "coarsetime"
version = "0.1.37"
description = "Time and duration crate optimized for speed (patched for MIPS)"
license = "BSD-2-Clause"
[features]
wasi-abi2 = ["dep:wasi-abi2"]
[lib]
name = "coarsetime"
path = "src/lib.rs"
[dependencies]
portable-atomic = { version = "1", default-features = false, features = ["fallback"] }
[target.'cfg(all(any(target_arch = "wasm32", target_arch = "wasm64"), target_os = "unknown"))'.dependencies.wasm-bindgen]
version = "0.2"
[target.'cfg(any(target_os = "wasix", target_os = "wasi"))'.dependencies.wasix]
version = "0.13"
[target.'cfg(not(any(target_os = "wasix", target_os = "wasi")))'.dependencies.libc]
version = "0.2"
[target.'cfg(target_os = "wasi")'.dependencies.wasi-abi2]
version = "0.14.7"
optional = true
package = "wasi"

View File

@@ -4,9 +4,6 @@
)))]
use std::time;
#[cfg(target_has_atomic = "64")]
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(not(target_has_atomic = "64"))]
use portable_atomic::{AtomicU64, Ordering};
use super::Duration;

View File

@@ -3,9 +3,6 @@ use std::mem::MaybeUninit;
use std::ops::*;
#[allow(unused_imports)]
use std::ptr::*;
#[cfg(target_has_atomic = "64")]
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(not(target_has_atomic = "64"))]
use portable_atomic::{AtomicU64, Ordering};
use super::duration::*;

View File

@@ -3,30 +3,48 @@ name = "shared"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
utoipa = { version = "5.4.0", features = ["axum_extras"] }
struct-patch = "0.5"
rmp-serde = "1.3"
bytes = "1"
http = "1"
# Leptos 0.8.7
leptos = { version = "0.8.15", features = ["nightly", "msgpack"] }
leptos_router = { version = "0.8.7", features = ["nightly"] }
leptos_axum = { version = "0.8.7", optional = true }
axum = { version = "0.8", features = ["macros"], optional = true }
# SSR Dependencies (XML-RPC & SCGI)
tokio = { version = "1", features = ["full"], optional = true }
thiserror = { version = "2", optional = true }
quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = true }
# Database
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"], optional = true }
anyhow = { version = "1.0", optional = true }
# Auth (SSR)
jsonwebtoken = { version = "9", optional = true }
cookie = { version = "0.18", features = ["percent-encode"], optional = true }
bcrypt = { version = "0.17", optional = true }
[features]
default = []
ssr = [
"dep:tokio",
"dep:bytes",
"dep:thiserror",
"dep:quick-xml",
"dep:leptos_axum",
"dep:sqlx",
"dep:anyhow",
"dep:jsonwebtoken",
"dep:cookie",
"dep:bcrypt",
"dep:axum",
"leptos/ssr",
"leptos_router/ssr",
]
hydrate = ["leptos/hydrate"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
utoipa = { version = "5.4.0", features = ["axum_extras"] }
# Leptos 0.8.7
leptos = { version = "0.8.7", features = ["nightly"] }
leptos_router = { version = "0.8.7", features = ["nightly"] }
leptos_axum = { version = "0.8.7", optional = true }
# SSR Dependencies (XML-RPC & SCGI)
tokio = { version = "1", features = ["full"], optional = true }
bytes = { version = "1", optional = true }
thiserror = { version = "2", optional = true }
quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = true }

1
shared/src/codec.rs Normal file
View File

@@ -0,0 +1 @@
pub use leptos::server_fn::codec::MsgPack;

View File

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use struct_patch::Patch;
use utoipa::ToSchema;
#[cfg(feature = "ssr")]
@@ -7,9 +8,27 @@ pub mod scgi;
#[cfg(feature = "ssr")]
pub mod xmlrpc;
#[cfg(feature = "ssr")]
pub mod db;
pub mod codec;
pub mod server_fns;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
#[derive(Clone, Debug)]
pub struct ServerContext {
pub scgi_socket_path: String,
}
#[cfg(feature = "ssr")]
#[derive(Clone)]
pub struct DbContext {
pub db: db::Db,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Patch)]
#[patch_derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Default)]
#[patch_name = "TorrentUpdate"]
pub struct Torrent {
pub hash: String,
pub name: String,
@@ -36,12 +55,8 @@ pub enum TorrentStatus {
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
#[serde(tag = "type", content = "data")]
pub enum AppEvent {
FullList {
torrents: Vec<Torrent>,
timestamp: u64,
},
FullList(Vec<Torrent>, u64),
Update(TorrentUpdate),
Stats(GlobalStats),
Notification(SystemNotification),
@@ -70,20 +85,8 @@ pub struct GlobalStats {
pub free_space: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct TorrentUpdate {
pub hash: String,
pub name: Option<String>,
pub size: Option<i64>,
pub down_rate: Option<i64>,
pub up_rate: Option<i64>,
pub percent_complete: Option<f64>,
pub completed: Option<i64>,
pub eta: Option<i64>,
pub status: Option<TorrentStatus>,
pub error_message: Option<String>,
pub label: Option<String>,
}
// REMOVED: Manual TorrentUpdate struct definition as it's now generated by Patch macro
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct TorrentActionRequest {

View File

@@ -0,0 +1,169 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::codec::MsgPack;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserResponse {
pub id: i64,
pub username: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // username
pub uid: i64, // user id
pub exp: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SetupStatus {
pub completed: bool,
}
#[server(GetSetupStatus, "/api/server_fns/GetSetupStatus", input = MsgPack, output = MsgPack)]
pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
use crate::DbContext;
let db_context = use_context::<DbContext>().ok_or_else(|| ServerFnError::new("DB Context missing"))?;
let has_users = db_context.db.has_users().await
.map_err(|e| ServerFnError::new(format!("DB error: {}", e)))?;
Ok(SetupStatus {
completed: has_users,
})
}
#[server(Setup, "/api/server_fns/Setup", input = MsgPack, output = MsgPack)]
pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> {
use crate::DbContext;
let db_context = use_context::<DbContext>().ok_or_else(|| ServerFnError::new("DB Context missing"))?;
// Check if setup is already done
let has_users = db_context.db.has_users().await.unwrap_or(false);
if has_users {
return Err(ServerFnError::new("Setup already completed"));
}
// Hash password (low cost for MIPS)
let password_hash = bcrypt::hash(&password, 6)
.map_err(|_| ServerFnError::new("Hashing error"))?;
db_context.db.create_user(&username, &password_hash).await
.map_err(|e| ServerFnError::new(format!("DB error: {}", e)))?;
Ok(())
}
#[server(Login, "/api/server_fns/Login", input = MsgPack, output = MsgPack)]
pub async fn login(username: String, password: String) -> Result<UserResponse, ServerFnError> {
use crate::DbContext;
use leptos_axum::ResponseOptions;
use jsonwebtoken::{encode, Header, EncodingKey};
use cookie::{Cookie, SameSite};
use std::time::{SystemTime, UNIX_EPOCH};
let db_context = use_context::<DbContext>().ok_or_else(|| ServerFnError::new("DB Context missing"))?;
let user_opt = db_context.db.get_user_by_username(&username).await
.map_err(|e| ServerFnError::new(format!("DB error: {}", e)))?;
if let Some((uid, password_hash)) = user_opt {
let valid = bcrypt::verify(&password, &password_hash).unwrap_or(false);
if !valid {
return Err(ServerFnError::new("Invalid credentials"));
}
let expiration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as usize + 24 * 3600; // 24 hours
let claims = Claims {
sub: username.clone(),
uid,
exp: expiration,
};
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
.map_err(|e| ServerFnError::new(format!("Token error: {}", e)))?;
let cookie = Cookie::build(("auth_token", token))
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.build();
if let Some(options) = use_context::<ResponseOptions>() {
options.insert_header(
axum::http::header::SET_COOKIE,
axum::http::HeaderValue::from_str(&cookie.to_string()).unwrap(),
);
}
Ok(UserResponse {
id: uid,
username,
})
} else {
Err(ServerFnError::new("Invalid credentials"))
}
}
#[server(Logout, "/api/server_fns/Logout", input = MsgPack, output = MsgPack)]
pub async fn logout() -> Result<(), ServerFnError> {
use leptos_axum::ResponseOptions;
use cookie::{Cookie, SameSite};
let cookie = Cookie::build(("auth_token", ""))
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.max_age(cookie::time::Duration::seconds(0))
.build();
if let Some(options) = use_context::<ResponseOptions>() {
options.insert_header(
axum::http::header::SET_COOKIE,
axum::http::HeaderValue::from_str(&cookie.to_string()).unwrap(),
);
}
Ok(())
}
#[server(GetUser, "/api/server_fns/GetUser", input = MsgPack, output = MsgPack)]
pub async fn get_user() -> Result<Option<UserResponse>, ServerFnError> {
use axum::http::HeaderMap;
use leptos_axum::extract;
use jsonwebtoken::{decode, Validation, DecodingKey};
let headers: HeaderMap = extract().await.map_err(|e| ServerFnError::new(format!("Extract error: {}", e)))?;
let cookie_header = headers.get(axum::http::header::COOKIE)
.and_then(|h| h.to_str().ok());
if let Some(cookie_str) = cookie_header {
for c_str in cookie_str.split(';') {
if let Ok(c) = cookie::Cookie::parse(c_str.trim()) {
if c.name() == "auth_token" {
let token = c.value();
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
);
if let Ok(data) = token_data {
return Ok(Some(UserResponse {
id: data.claims.uid,
username: data.claims.sub,
}));
}
}
}
}
}
Ok(None)
}

View File

@@ -1,26 +1,4 @@
use leptos::*;
use leptos::prelude::*;
#[cfg(feature = "ssr")]
use crate::xmlrpc::{self, RtorrentClient};
#[server(GetVersion, "/api/server_fns")]
pub async fn get_version() -> Result<String, ServerFnError> {
let socket_path = std::env::var("RTORRENT_SOCKET").unwrap_or_else(|_| "/tmp/rtorrent.sock".to_string());
#[cfg(feature = "ssr")]
{
let client = RtorrentClient::new(&socket_path);
match client.call("system.client_version", &[]).await {
Ok(xml) => {
let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml);
Ok(version)
},
Err(e) => Err(ServerFnError::ServerError(e.to_string())),
}
}
#[cfg(not(feature = "ssr"))]
{
unreachable!()
}
}
pub mod torrent;
pub mod settings;
pub mod push;
pub mod auth;

View File

@@ -0,0 +1,22 @@
use leptos::prelude::*;
#[server(GetPushPublicKey, "/api/server_fns")]
pub async fn get_public_key() -> Result<String, ServerFnError> {
let key = std::env::var("VAPID_PUBLIC_KEY")
.map_err(|_| ServerFnError::new("VAPID_PUBLIC_KEY not configured"))?;
Ok(key)
}
#[server(SubscribePush, "/api/server_fns")]
pub async fn subscribe_push(
endpoint: String,
p256dh: String,
auth: String,
) -> Result<(), ServerFnError> {
let db_ctx = expect_context::<crate::DbContext>();
db_ctx
.db
.save_push_subscription(&endpoint, &p256dh, &auth)
.await
.map_err(|e| ServerFnError::new(format!("Failed to save subscription: {}", e)))
}

View File

@@ -0,0 +1,58 @@
use leptos::prelude::*;
use crate::GlobalLimitRequest;
#[server(GetGlobalLimits, "/api/server_fns")]
pub async fn get_global_limits() -> Result<GlobalLimitRequest, ServerFnError> {
use crate::xmlrpc::{self, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let down = match client.call("throttle.global_down.max_rate", &[]).await {
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
Err(_) => -1,
};
let up = match client.call("throttle.global_up.max_rate", &[]).await {
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
Err(_) => -1,
};
Ok(GlobalLimitRequest {
max_download_rate: Some(down),
max_upload_rate: Some(up),
})
}
#[server(SetGlobalLimits, "/api/server_fns")]
pub async fn set_global_limits(
max_download_rate: Option<i64>,
max_upload_rate: Option<i64>,
) -> Result<(), ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
if let Some(down) = max_download_rate {
let down_kb = down / 1024;
client
.call(
"throttle.global_down.max_rate.set_kb",
&[RpcParam::from(""), RpcParam::Int(down_kb)],
)
.await
.map_err(|e| ServerFnError::new(format!("Failed to set down limit: {}", e)))?;
}
if let Some(up) = max_upload_rate {
let up_kb = up / 1024;
client
.call(
"throttle.global_up.max_rate.set_kb",
&[RpcParam::from(""), RpcParam::Int(up_kb)],
)
.await
.map_err(|e| ServerFnError::new(format!("Failed to set up limit: {}", e)))?;
}
Ok(())
}

View File

@@ -0,0 +1,274 @@
use leptos::prelude::*;
use crate::{TorrentFile, TorrentPeer, TorrentTracker};
#[server(AddTorrent, "/api/server_fns")]
pub async fn add_torrent(uri: String) -> Result<(), ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![RpcParam::from(""), RpcParam::from(uri.as_str())];
match client.call("load.start", &params).await {
Ok(response) => {
if response.contains("faultCode") {
return Err(ServerFnError::new("rTorrent returned fault"));
}
Ok(())
}
Err(e) => Err(ServerFnError::new(format!("Failed to add torrent: {}", e))),
}
}
#[server(TorrentAction, "/api/server_fns")]
pub async fn torrent_action(hash: String, action: String) -> Result<String, ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
if action == "delete_with_data" {
return delete_torrent_with_data_inner(&client, &hash).await;
}
let method = match action.as_str() {
"start" => "d.start",
"stop" => "d.stop",
"delete" => "d.erase",
_ => return Err(ServerFnError::new("Invalid action")),
};
let params = vec![RpcParam::from(hash.as_str())];
match client.call(method, &params).await {
Ok(_) => Ok("Action executed".to_string()),
Err(e) => Err(ServerFnError::new(format!("RPC error: {}", e))),
}
}
#[cfg(feature = "ssr")]
async fn delete_torrent_with_data_inner(
client: &crate::xmlrpc::RtorrentClient,
hash: &str,
) -> Result<String, ServerFnError> {
use crate::xmlrpc::{parse_string_response, RpcParam};
let params_hash = vec![RpcParam::from(hash)];
let path_xml = client
.call("d.base_path", &params_hash)
.await
.map_err(|e| ServerFnError::new(format!("Failed to call rTorrent: {}", e)))?;
let path = parse_string_response(&path_xml)
.map_err(|e| ServerFnError::new(format!("Failed to parse path: {}", e)))?;
let root_xml = client
.call("directory.default", &[])
.await
.map_err(|e| ServerFnError::new(format!("Failed to get download root: {}", e)))?;
let root_path_str = parse_string_response(&root_xml)
.map_err(|e| ServerFnError::new(format!("Failed to parse root path: {}", e)))?;
let root_path = tokio::fs::canonicalize(std::path::Path::new(&root_path_str))
.await
.map_err(|e| ServerFnError::new(format!("Invalid download root: {}", e)))?;
let target_path_raw = std::path::Path::new(&path);
if !tokio::fs::try_exists(target_path_raw).await.unwrap_or(false) {
client
.call("d.erase", &params_hash)
.await
.map_err(|e| ServerFnError::new(format!("Failed to erase torrent: {}", e)))?;
return Ok("Torrent removed (Data not found)".to_string());
}
let target_path = tokio::fs::canonicalize(target_path_raw)
.await
.map_err(|e| ServerFnError::new(format!("Invalid data path: {}", e)))?;
if !target_path.starts_with(&root_path) {
return Err(ServerFnError::new(
"Security Error: Cannot delete files outside download directory",
));
}
if target_path == root_path {
return Err(ServerFnError::new(
"Security Error: Cannot delete the download root directory",
));
}
client
.call("d.erase", &params_hash)
.await
.map_err(|e| ServerFnError::new(format!("Failed to erase torrent: {}", e)))?;
let delete_result = if target_path.is_dir() {
tokio::fs::remove_dir_all(&target_path).await
} else {
tokio::fs::remove_file(&target_path).await
};
match delete_result {
Ok(_) => Ok("Torrent and data deleted".to_string()),
Err(e) => Err(ServerFnError::new(format!("Failed to delete data: {}", e))),
}
}
#[server(GetFiles, "/api/server_fns")]
pub async fn get_files(hash: String) -> Result<Vec<TorrentFile>, ServerFnError> {
use crate::xmlrpc::{parse_multicall_response, RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("f.path="),
RpcParam::from("f.size_bytes="),
RpcParam::from("f.completed_chunks="),
RpcParam::from("f.priority="),
];
let xml = client
.call("f.multicall", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
let rows = parse_multicall_response(&xml)
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
Ok(rows
.into_iter()
.enumerate()
.map(|(idx, row)| TorrentFile {
index: idx as u32,
path: row.get(0).cloned().unwrap_or_default(),
size: row.get(1).and_then(|s| s.parse().ok()).unwrap_or(0),
completed_chunks: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
priority: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
})
.collect())
}
#[server(GetPeers, "/api/server_fns")]
pub async fn get_peers(hash: String) -> Result<Vec<TorrentPeer>, ServerFnError> {
use crate::xmlrpc::{parse_multicall_response, RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("p.address="),
RpcParam::from("p.client_version="),
RpcParam::from("p.down_rate="),
RpcParam::from("p.up_rate="),
RpcParam::from("p.completed_percent="),
];
let xml = client
.call("p.multicall", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
let rows = parse_multicall_response(&xml)
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
Ok(rows
.into_iter()
.map(|row| TorrentPeer {
ip: row.get(0).cloned().unwrap_or_default(),
client: row.get(1).cloned().unwrap_or_default(),
down_rate: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
up_rate: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
progress: row.get(4).and_then(|s| s.parse().ok()).unwrap_or(0.0),
})
.collect())
}
#[server(GetTrackers, "/api/server_fns")]
pub async fn get_trackers(hash: String) -> Result<Vec<TorrentTracker>, ServerFnError> {
use crate::xmlrpc::{parse_multicall_response, RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("t.url="),
RpcParam::from("t.activity_date_last="),
RpcParam::from("t.message="),
];
let xml = client
.call("t.multicall", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
let rows = parse_multicall_response(&xml)
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
Ok(rows
.into_iter()
.map(|row| TorrentTracker {
url: row.get(0).cloned().unwrap_or_default(),
status: "Unknown".to_string(),
message: row.get(2).cloned().unwrap_or_default(),
})
.collect())
}
#[server(SetFilePriority, "/api/server_fns")]
pub async fn set_file_priority(
hash: String,
file_index: u32,
priority: u8,
) -> Result<(), ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let target = format!("{}:f{}", hash, file_index);
let params = vec![
RpcParam::from(target.as_str()),
RpcParam::from(priority as i64),
];
client
.call("f.set_priority", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
let _ = client
.call("d.update_priorities", &[RpcParam::from(hash.as_str())])
.await;
Ok(())
}
#[server(SetLabel, "/api/server_fns")]
pub async fn set_label(hash: String, label: String) -> Result<(), ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![RpcParam::from(hash.as_str()), RpcParam::from(label)];
client
.call("d.custom1.set", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
Ok(())
}
#[server(GetVersion, "/api/server_fns")]
pub async fn get_version() -> Result<String, ServerFnError> {
use crate::xmlrpc::{parse_string_response, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
match client.call("system.client_version", &[]).await {
Ok(xml) => {
let version = parse_string_response(&xml).unwrap_or(xml);
Ok(version)
}
Err(e) => Err(ServerFnError::new(format!("Failed to get version: {}", e))),
}
}

View File

@@ -1 +0,0 @@
{"v":1}

View File

@@ -1,6 +0,0 @@
{
"git": {
"sha1": "831c97016aa3d8f7851999aa1deea8407e7cbd42"
},
"path_in_vcs": ""
}

View File

@@ -1,8 +0,0 @@
version: 2
updates:
- package-ecosystem: cargo
directory: "/"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10

View File

@@ -1,17 +0,0 @@
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,4 +0,0 @@
target
Cargo.lock
.vscode
zig-cache

View File

@@ -1,82 +0,0 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2018"
name = "coarsetime"
version = "0.1.37"
authors = ["Frank Denis <github@pureftpd.org>"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Time and duration crate optimized for speed"
homepage = "https://github.com/jedisct1/rust-coarsetime"
readme = "README.md"
keywords = [
"time",
"date",
"duration",
]
categories = [
"concurrency",
"date-and-time",
"os",
]
license = "BSD-2-Clause"
repository = "https://github.com/jedisct1/rust-coarsetime"
[features]
wasi-abi2 = ["dep:wasi-abi2"]
[lib]
name = "coarsetime"
path = "src/lib.rs"
[[bench]]
name = "benchmark"
path = "benches/benchmark.rs"
harness = false
[dev-dependencies.benchmark-simple]
version = "0.1.10"
[dependencies.portable-atomic]
version = "1.6"
[target.'cfg(all(any(target_arch = "wasm32", target_arch = "wasm64"), target_os = "unknown"))'.dependencies.wasm-bindgen]
version = "0.2"
[target.'cfg(any(target_os = "wasix", target_os = "wasi"))'.dependencies.wasix]
version = "0.13"
[target.'cfg(not(any(target_os = "wasix", target_os = "wasi")))'.dependencies.libc]
version = "0.2"
[target.'cfg(target_os = "wasi")'.dependencies.wasi-abi2]
version = "0.14.7"
optional = true
package = "wasi"
[profile.bench]
codegen-units = 1
[profile.dev]
overflow-checks = true
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
incremental = false

View File

@@ -1,47 +0,0 @@
[package]
name = "coarsetime"
version = "0.1.37"
description = "Time and duration crate optimized for speed"
authors = ["Frank Denis <github@pureftpd.org>"]
keywords = ["time", "date", "duration"]
readme = "README.md"
license = "BSD-2-Clause"
homepage = "https://github.com/jedisct1/rust-coarsetime"
repository = "https://github.com/jedisct1/rust-coarsetime"
categories = ["concurrency", "date-and-time", "os"]
edition = "2018"
[features]
wasi-abi2 = ["dep:wasi-abi2"]
[target.'cfg(not(any(target_os = "wasix", target_os = "wasi")))'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "wasi")'.dependencies]
wasi-abi2 = { package = "wasi", version = "0.14.7", optional = true }
[target.'cfg(any(target_os = "wasix", target_os = "wasi"))'.dependencies]
wasix = "0.13"
[target.'cfg(all(any(target_arch = "wasm32", target_arch = "wasm64"), target_os = "unknown"))'.dependencies]
wasm-bindgen = "0.2"
[dev-dependencies]
benchmark-simple = "0.1.10"
[profile.bench]
codegen-units = 1
[[bench]]
name = "benchmark"
harness = false
[profile.release]
lto = true
panic = "abort"
opt-level = 3
codegen-units = 1
incremental = false
[profile.dev]
overflow-checks=true

View File

@@ -1,25 +0,0 @@
BSD 2-Clause License
Copyright (c) 2016-2026, Frank Denis
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -1,90 +0,0 @@
[![Documentation](https://docs.rs/coarsetime/badge.svg)](https://docs.rs/coarsetime)
[![Windows build status](https://ci.appveyor.com/api/projects/status/xlbhk9850dvl5ylh?svg=true)](https://ci.appveyor.com/project/jedisct1/rust-coarsetime)
# coarsetime
A Rust crate to make time measurements, that focuses on speed, API stability and portability.
This crate is a partial replacement for the `Time` and `Duration` structures
from the standard library, with the following differences:
* Speed is privileged over accuracy. In particular, `CLOCK_MONOTONIC_COARSE` is
used to retrieve the clock value on Linux systems, and transformations avoid
operations that can be slow on non-Intel systems.
* The number of system calls can be kept to a minimum. The "most recent
timestamp" is always kept in memory. It can be read with just a load operation,
and can be updated only as frequently as necessary.
* The API is stable, and the same for all platforms. Unlike the standard library, it doesn't silently compile functions that do nothing but panic at runtime on some platforms.
# Installation
`coarsetime` is available on [crates.io](https://crates.io/crates/coarsetime)
and works on Rust stable, beta, and nightly.
Windows and Unix-like systems are supported.
Available feature:
* `wasi-abi2`: when targeting WASI, use the second preview of the ABI. Default is to use the regular WASI-core ABI.
# Documentation
[API documentation](https://docs.rs/coarsetime)
# Example
```rust
extern crate coarsetime;
use coarsetime::{Duration, Instant, Updater};
// Get the current instant. This may require a system call, but it may also
// be faster than the stdlib equivalent.
let now = Instant::now();
// Get the latest known instant. This operation is super fast.
// In this case, the value will be identical to `now`, because we haven't
// updated the latest known instant yet.
let ts1 = Instant::recent();
// Update the latest known instant. This may require a system call.
// Note that a call to `Instant::now()` also updates the stored instant.
Instant::update();
// Now, we may get a different instant. This call is also super fast.
let ts2 = Instant::recent();
// Compute the time elapsed between ts2 and ts1.
let elapsed_ts2_ts1 = ts2.duration_since(ts1);
// Operations such as `+` and `-` between `Instant` and `Duration` are also
// available.
let elapsed_ts2_ts1 = ts2 - ts1;
// Returns the time elapsed since ts1.
// This retrieves the actual current time, and may require a system call.
let elapsed_since_ts1 = ts1.elapsed();
// Returns the approximate time elapsed since ts1.
// This uses the latest known instant, and is super fast.
let elapsed_since_recent = ts1.elapsed_since_recent();
// Instant::update() should be called periodically, for example using an
// event loop. Alternatively, the crate provides an easy way to spawn a
// background task that will periodically update the latest known instant.
// Here, the update will happen every 250ms.
let updater = Updater::new(250).start().unwrap();
// From now on, Instant::recent() will always return an approximation of the
// current instant.
let ts3 = Instant::recent();
// Stop the task.
updater.stop().unwrap();
// Returns the elapsed time since the UNIX epoch
let unix_timestamp = Clock::now_since_epoch();
// Returns an approximation of the elapsed time since the UNIX epoch, based on
// the latest time update
let unix_timestamp_approx = Clock::recent_since_epoch();
```

View File

@@ -1,61 +0,0 @@
use benchmark_simple::*;
use coarsetime::*;
use std::time;
fn main() {
let options = &Options {
iterations: 250_000,
warmup_iterations: 25_000,
min_samples: 5,
max_samples: 10,
max_rsd: 1.0,
..Default::default()
};
bench_coarsetime_now(options);
bench_coarsetime_recent(options);
bench_coarsetime_elapsed(options);
bench_coarsetime_elapsed_since_recent(options);
bench_stdlib_now(options);
bench_stdlib_elapsed(options);
}
fn bench_coarsetime_now(options: &Options) {
let b = Bench::new();
Instant::update();
let res = b.run(options, Instant::now);
println!("coarsetime_now(): {}", res.throughput(1));
}
fn bench_coarsetime_recent(options: &Options) {
let b = Bench::new();
Instant::update();
let res = b.run(options, Instant::recent);
println!("coarsetime_recent(): {}", res.throughput(1));
}
fn bench_coarsetime_elapsed(options: &Options) {
let b = Bench::new();
let ts = Instant::now();
let res = b.run(options, || ts.elapsed());
println!("coarsetime_elapsed(): {}", res.throughput(1));
}
fn bench_coarsetime_elapsed_since_recent(options: &Options) {
let b = Bench::new();
let ts = Instant::now();
let res = b.run(options, || ts.elapsed_since_recent());
println!("coarsetime_since_recent(): {}", res.throughput(1));
}
fn bench_stdlib_now(options: &Options) {
let b = Bench::new();
let res = b.run(options, time::Instant::now);
println!("stdlib_now(): {}", res.throughput(1));
}
fn bench_stdlib_elapsed(options: &Options) {
let b = Bench::new();
let ts = time::Instant::now();
let res = b.run(options, || ts.elapsed());
println!("stdlib_elapsed(): {}", res.throughput(1));
}

View File

@@ -1,55 +0,0 @@
use std::thread::sleep;
use std::time;
#[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
use super::Updater;
use super::{Clock, Duration, Instant};
#[test]
fn tests() {
let ts = Instant::now();
let d = Duration::from_secs(2);
sleep(time::Duration::new(3, 0));
let elapsed = ts.elapsed().as_secs();
println!("Elapsed: {elapsed} secs");
assert!(elapsed >= 2);
assert!(elapsed < 100);
assert!(ts.elapsed_since_recent() > d);
let ts = Instant::now();
sleep(time::Duration::new(1, 0));
assert_eq!(Instant::recent(), ts);
Instant::update();
assert!(Instant::recent() > ts);
let clock_now = Clock::recent_since_epoch();
sleep(time::Duration::new(1, 0));
assert_eq!(Clock::recent_since_epoch(), clock_now);
assert!(Clock::now_since_epoch() > clock_now);
#[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
tests_updater();
}
#[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
#[test]
fn tests_updater() {
let updater = Updater::new(250)
.start()
.expect("Unable to start a background updater");
let ts = Instant::recent();
let clock_recent = Clock::recent_since_epoch();
sleep(time::Duration::new(2, 0));
assert!(Clock::recent_since_epoch() > clock_recent);
assert!(Instant::recent() != ts);
updater.stop().unwrap();
let clock_recent = Clock::recent_since_epoch();
sleep(time::Duration::new(1, 0));
assert_eq!(Clock::recent_since_epoch(), clock_recent);
}
#[test]
fn tests_duration() {
let duration = Duration::from_days(1000);
assert_eq!(duration.as_days(), 1000);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.