Compare commits
16 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9447a66cc1 | ||
|
|
45247a020e | ||
|
|
77b77c7775 | ||
|
|
8ef3008cb8 | ||
|
|
ca1dd0caac | ||
|
|
ad336789d9 | ||
|
|
fa248d87ae | ||
|
|
d8a9e9e137 | ||
|
|
ca31b4018f | ||
|
|
7707bfff15 | ||
|
|
376615813b | ||
|
|
fddc81365b | ||
|
|
8815727620 | ||
|
|
c85c75659e | ||
|
|
4b3e713657 | ||
|
|
c2bf6e6fd5 |
@@ -26,7 +26,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
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'ın optimizasyonunu kapalı (0) tutuyoruz çünkü Cargo.toml'daki opt-level='z' zaten o işi yapıyor.
|
||||||
trunk build --release
|
trunk build --release
|
||||||
|
|
||||||
|
|||||||
471
Cargo.lock
generated
471
Cargo.lock
generated
@@ -320,17 +320,19 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
"governor",
|
"governor",
|
||||||
|
"jsonwebtoken",
|
||||||
"leptos",
|
"leptos",
|
||||||
"leptos_axum",
|
"leptos_axum",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"openssl",
|
"openssl",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"rmp-serde",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"shared",
|
"shared",
|
||||||
"sqlx",
|
"struct-patch",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1258,13 +1260,33 @@ dependencies = [
|
|||||||
"gloo-timers",
|
"gloo-timers",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"leptos",
|
"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-use",
|
||||||
"leptos_router",
|
"leptos_router",
|
||||||
"log",
|
"log",
|
||||||
|
"rmp-serde",
|
||||||
"serde",
|
"serde",
|
||||||
"serde-wasm-bindgen",
|
"serde-wasm-bindgen",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"shared",
|
"shared",
|
||||||
|
"struct-patch",
|
||||||
"tailwind_fuse",
|
"tailwind_fuse",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -1398,8 +1420,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2026,6 +2050,21 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "jwt-simple"
|
name = "jwt-simple"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -2117,6 +2156,334 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "leptos-use"
|
name = "leptos-use"
|
||||||
version = "0.16.3"
|
version = "0.16.3"
|
||||||
@@ -2557,6 +2924,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -3237,6 +3614,39 @@ dependencies = [
|
|||||||
"subtle",
|
"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]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -3651,12 +4061,20 @@ dependencies = [
|
|||||||
name = "shared"
|
name = "shared"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"axum",
|
||||||
|
"bcrypt",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"cookie",
|
||||||
|
"jsonwebtoken",
|
||||||
"leptos",
|
"leptos",
|
||||||
"leptos_axum",
|
"leptos_axum",
|
||||||
"leptos_router",
|
"leptos_router",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
|
"rmp-serde",
|
||||||
"serde",
|
"serde",
|
||||||
|
"sqlx",
|
||||||
|
"struct-patch",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
@@ -3704,6 +4122,18 @@ version = "0.3.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
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]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -3997,6 +4427,26 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
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]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -4107,6 +4557,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7ca71fb01735fbc6fa13e9390d7a3037dde97053c0b65c0c72c0159cd009d26b"
|
checksum = "7ca71fb01735fbc6fa13e9390d7a3037dde97053c0b65c0c72c0159cd009d26b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nom",
|
"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]]
|
[[package]]
|
||||||
@@ -4640,6 +5103,12 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ tower = { version = "0.5", features = ["util", "timeout"] }
|
|||||||
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-full"] }
|
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-full"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
rmp-serde = "1.3"
|
||||||
|
struct-patch = "0.5"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
tokio-stream = "0.1"
|
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 }
|
web-push = { version = "0.10", default-features = false, features = ["hyper-client"], optional = true }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
openssl = { version = "0.10", features = ["vendored"], optional = true }
|
openssl = { version = "0.10", features = ["vendored"], optional = true }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
|
||||||
bcrypt = "0.17.0"
|
bcrypt = "0.17.0"
|
||||||
axum-extra = { version = "0.10", features = ["cookie"] }
|
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
@@ -44,4 +45,5 @@ governor = "0.10.4"
|
|||||||
|
|
||||||
# Leptos
|
# Leptos
|
||||||
leptos = { version = "0.8.15", features = ["nightly"] }
|
leptos = { version = "0.8.15", features = ["nightly"] }
|
||||||
leptos_axum = { version = "0.8.7" }
|
leptos_axum = { version = "0.8.7" }
|
||||||
|
jsonwebtoken = "9"
|
||||||
@@ -9,106 +9,54 @@ pub enum DiffResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> 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() {
|
if old.len() != new.len() {
|
||||||
return DiffResult::FullUpdate;
|
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();
|
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 {
|
for new_t in new {
|
||||||
if !old_map.contains_key(new_t.hash.as_str()) {
|
if !old_map.contains_key(new_t.hash.as_str()) {
|
||||||
return DiffResult::FullUpdate;
|
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();
|
let mut events = Vec::new();
|
||||||
|
|
||||||
for new_t in 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 old_t = old_map.get(new_t.hash.as_str()).unwrap();
|
||||||
|
|
||||||
let mut update = TorrentUpdate {
|
// Manuel diff creating TorrentUpdate (which is the Patch struct)
|
||||||
hash: new_t.hash.clone(),
|
let mut patch = TorrentUpdate::default();
|
||||||
name: None,
|
|
||||||
size: None,
|
|
||||||
down_rate: None,
|
|
||||||
up_rate: None,
|
|
||||||
percent_complete: None,
|
|
||||||
completed: None,
|
|
||||||
eta: None,
|
|
||||||
status: None,
|
|
||||||
error_message: None,
|
|
||||||
label: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut has_changes = false;
|
let mut has_changes = false;
|
||||||
|
|
||||||
// Alanları karşılaştır
|
if old_t.name != new_t.name { patch.name = Some(new_t.name.clone()); has_changes = true; }
|
||||||
if old_t.name != new_t.name {
|
if old_t.size != new_t.size { patch.size = Some(new_t.size); has_changes = true; }
|
||||||
update.name = Some(new_t.name.clone());
|
if old_t.down_rate != new_t.down_rate { patch.down_rate = Some(new_t.down_rate); has_changes = true; }
|
||||||
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.size != new_t.size {
|
if old_t.eta != new_t.eta { patch.eta = Some(new_t.eta); has_changes = true; }
|
||||||
update.size = Some(new_t.size);
|
if (old_t.percent_complete - new_t.percent_complete).abs() > 0.01 {
|
||||||
has_changes = true;
|
patch.percent_complete = Some(new_t.percent_complete);
|
||||||
}
|
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.percent_complete < 100.0 && new_t.percent_complete >= 100.0 {
|
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 {
|
events.push(AppEvent::Notification(SystemNotification {
|
||||||
level: NotificationLevel::Success,
|
level: NotificationLevel::Success,
|
||||||
message: format!("Torrent tamamlandı: {}", new_t.name),
|
message: format!("Torrent tamamlandı: {}", new_t.name),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if old_t.completed != new_t.completed {
|
if old_t.status != new_t.status { patch.status = Some(new_t.status.clone()); has_changes = true; }
|
||||||
update.completed = Some(new_t.completed);
|
if old_t.error_message != new_t.error_message { patch.error_message = Some(new_t.error_message.clone()); has_changes = true; }
|
||||||
has_changes = true;
|
if old_t.label != new_t.label { patch.label = Some(new_t.label.clone()); 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 has_changes {
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,21 +49,3 @@ pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "push-notifications")]
|
|
||||||
pub async fn get_push_public_key_handler(
|
|
||||||
axum::extract::State(state): axum::extract::State<crate::AppState>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let public_key = state.push_store.get_public_key();
|
|
||||||
(StatusCode::OK, axum::extract::Json(serde_json::json!({ "publicKey": public_key }))).into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "push-notifications")]
|
|
||||||
pub async fn subscribe_push_handler(
|
|
||||||
axum::extract::State(state): axum::extract::State<crate::AppState>,
|
|
||||||
axum::extract::Json(subscription): axum::extract::Json<crate::push::PushSubscription>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
tracing::info!("Received push subscription: {:?}", subscription);
|
|
||||||
state.push_store.add_subscription(subscription).await;
|
|
||||||
(StatusCode::OK, "Subscription saved").into_response()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
mod db;
|
|
||||||
mod diff;
|
mod diff;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
#[cfg(feature = "push-notifications")]
|
#[cfg(feature = "push-notifications")]
|
||||||
@@ -42,7 +41,7 @@ pub struct AppState {
|
|||||||
pub tx: Arc<watch::Sender<Vec<Torrent>>>,
|
pub tx: Arc<watch::Sender<Vec<Torrent>>>,
|
||||||
pub event_bus: broadcast::Sender<AppEvent>,
|
pub event_bus: broadcast::Sender<AppEvent>,
|
||||||
pub scgi_socket_path: String,
|
pub scgi_socket_path: String,
|
||||||
pub db: db::Db,
|
pub db: shared::db::Db,
|
||||||
#[cfg(feature = "push-notifications")]
|
#[cfg(feature = "push-notifications")]
|
||||||
pub push_store: push::PushSubscriptionStore,
|
pub push_store: push::PushSubscriptionStore,
|
||||||
pub notify_poll: Arc<tokio::sync::Notify>,
|
pub notify_poll: Arc<tokio::sync::Notify>,
|
||||||
@@ -56,10 +55,9 @@ async fn auth_middleware(
|
|||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
// Skip auth for public paths
|
// Skip auth for public paths
|
||||||
let path = request.uri().path();
|
let path = request.uri().path();
|
||||||
if path.starts_with("/api/auth/login")
|
if path.starts_with("/api/server_fns/Login") // Login server fn
|
||||||
|| path.starts_with("/api/auth/check") // Used by frontend to decide where to go
|
|| path.starts_with("/api/server_fns/GetSetupStatus")
|
||||||
|| path.starts_with("/api/setup")
|
|| path.starts_with("/api/server_fns/Setup")
|
||||||
|| path.starts_with("/api/server_fns")
|
|
||||||
|| path.starts_with("/swagger-ui")
|
|| path.starts_with("/swagger-ui")
|
||||||
|| path.starts_with("/api-docs")
|
|| path.starts_with("/api-docs")
|
||||||
|| !path.starts_with("/api/") // Allow static files (frontend)
|
|| !path.starts_with("/api/") // Allow static files (frontend)
|
||||||
@@ -69,9 +67,19 @@ async fn auth_middleware(
|
|||||||
|
|
||||||
// Check token
|
// Check token
|
||||||
if let Some(token) = jar.get("auth_token") {
|
if let Some(token) = jar.get("auth_token") {
|
||||||
match state.db.get_session_user(token.value()).await {
|
use jsonwebtoken::{decode, Validation, DecodingKey};
|
||||||
Ok(Some(_)) => return Ok(next.run(request).await),
|
use shared::server_fns::auth::Claims;
|
||||||
_ => {} // Invalid
|
|
||||||
|
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,46 +111,6 @@ struct Args {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "swagger")]
|
#[cfg(feature = "swagger")]
|
||||||
#[cfg(feature = "push-notifications")]
|
|
||||||
#[derive(OpenApi)]
|
|
||||||
#[openapi(
|
|
||||||
paths(
|
|
||||||
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,
|
|
||||||
shared::TorrentActionRequest,
|
|
||||||
shared::Torrent,
|
|
||||||
shared::TorrentStatus,
|
|
||||||
shared::TorrentFile,
|
|
||||||
shared::TorrentPeer,
|
|
||||||
shared::TorrentTracker,
|
|
||||||
shared::SetFilePriorityRequest,
|
|
||||||
shared::SetLabelRequest,
|
|
||||||
shared::GlobalLimitRequest,
|
|
||||||
push::PushSubscription,
|
|
||||||
push::PushKeys,
|
|
||||||
handlers::auth::LoginRequest,
|
|
||||||
handlers::setup::SetupRequest,
|
|
||||||
handlers::setup::SetupStatusResponse,
|
|
||||||
handlers::auth::UserResponse
|
|
||||||
)
|
|
||||||
),
|
|
||||||
tags(
|
|
||||||
(name = "vibetorrent", description = "VibeTorrent API")
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
struct ApiDoc;
|
|
||||||
|
|
||||||
#[cfg(feature = "swagger")]
|
|
||||||
#[cfg(not(feature = "push-notifications"))]
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
@@ -194,19 +162,9 @@ async fn main() {
|
|||||||
|
|
||||||
// Initialize Database
|
// Initialize Database
|
||||||
tracing::info!("Connecting to database: {}", args.db_url);
|
tracing::info!("Connecting to database: {}", args.db_url);
|
||||||
// Ensure the db file exists if it's sqlite
|
// Redundant manual creation removed, shared::db handles it
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
Ok(db) => db,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to connect to database: {}", e);
|
tracing::error!("Failed to connect to database: {}", e);
|
||||||
@@ -388,10 +346,7 @@ async fn main() {
|
|||||||
|
|
||||||
match diff::diff_torrents(&previous_torrents, &new_torrents) {
|
match diff::diff_torrents(&previous_torrents, &new_torrents) {
|
||||||
diff::DiffResult::FullUpdate => {
|
diff::DiffResult::FullUpdate => {
|
||||||
let _ = event_bus_tx.send(AppEvent::FullList {
|
let _ = event_bus_tx.send(AppEvent::FullList(new_torrents.clone(), now));
|
||||||
torrents: new_torrents.clone(),
|
|
||||||
timestamp: now,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
diff::DiffResult::Partial(updates) => {
|
diff::DiffResult::Partial(updates) => {
|
||||||
for update in updates {
|
for update in updates {
|
||||||
@@ -470,26 +425,23 @@ async fn main() {
|
|||||||
|
|
||||||
// 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 scgi_path_for_ctx = args.socket.clone();
|
||||||
|
let db_for_ctx = db.clone();
|
||||||
let app = app
|
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))
|
|
||||||
.route("/api/events", get(sse::sse_handler))
|
.route("/api/events", get(sse::sse_handler))
|
||||||
.route("/api/server_fns/{*fn_name}", post({
|
.route("/api/server_fns/{*fn_name}", post({
|
||||||
let scgi_path = scgi_path_for_ctx.clone();
|
let scgi_path = scgi_path_for_ctx.clone();
|
||||||
|
let db = db_for_ctx.clone();
|
||||||
move |req: Request<Body>| {
|
move |req: Request<Body>| {
|
||||||
|
let scgi_path = scgi_path.clone();
|
||||||
|
let db = db.clone();
|
||||||
leptos_axum::handle_server_fns_with_context(
|
leptos_axum::handle_server_fns_with_context(
|
||||||
move || {
|
move || {
|
||||||
leptos::context::provide_context(shared::ServerContext {
|
leptos::context::provide_context(shared::ServerContext {
|
||||||
scgi_socket_path: scgi_path.clone(),
|
scgi_socket_path: scgi_path.clone(),
|
||||||
});
|
});
|
||||||
|
leptos::context::provide_context(shared::DbContext {
|
||||||
|
db: db.clone(),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
req,
|
req,
|
||||||
)
|
)
|
||||||
@@ -497,11 +449,6 @@ async fn main() {
|
|||||||
}))
|
}))
|
||||||
.fallback(handlers::static_handler);
|
.fallback(handlers::static_handler);
|
||||||
|
|
||||||
#[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));
|
|
||||||
|
|
||||||
let app = app
|
let app = app
|
||||||
.layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware))
|
.layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use web_push::{
|
|||||||
};
|
};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
|
||||||
use crate::db::Db;
|
use shared::db::Db;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct PushSubscription {
|
pub struct PushSubscription {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use futures::stream::{self, Stream};
|
|||||||
use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus};
|
use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use tokio_stream::StreamExt;
|
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
|
// Field definitions to keep query and parser in sync
|
||||||
mod fields {
|
mod fields {
|
||||||
@@ -194,7 +196,7 @@ pub async fn fetch_global_stats(client: &RtorrentClient) -> Result<GlobalStats,
|
|||||||
|
|
||||||
pub async fn sse_handler(
|
pub async fn sse_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
) -> impl IntoResponse {
|
||||||
// Notify background worker to wake up and poll immediately
|
// Notify background worker to wake up and poll immediately
|
||||||
state.notify_poll.notify_one();
|
state.notify_poll.notify_one();
|
||||||
|
|
||||||
@@ -208,13 +210,10 @@ pub async fn sse_handler(
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
|
|
||||||
let event_data = AppEvent::FullList {
|
let event_data = AppEvent::FullList(initial_torrents, timestamp);
|
||||||
torrents: initial_torrents,
|
|
||||||
timestamp,
|
|
||||||
};
|
|
||||||
|
|
||||||
match serde_json::to_string(&event_data) {
|
match rmp_serde::to_vec(&event_data) {
|
||||||
Ok(json) => Event::default().data(json),
|
Ok(bytes) => Event::default().data(BASE64.encode(bytes)),
|
||||||
Err(_) => Event::default().comment("init_error"),
|
Err(_) => Event::default().comment("init_error"),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -226,10 +225,10 @@ pub async fn sse_handler(
|
|||||||
let rx = state.event_bus.subscribe();
|
let rx = state.event_bus.subscribe();
|
||||||
let update_stream = stream::unfold(rx, |mut rx| async move {
|
let update_stream = stream::unfold(rx, |mut rx| async move {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(event) => match serde_json::to_string(&event) {
|
Ok(event) => match rmp_serde::to_vec(&event) {
|
||||||
Ok(json) => Some((Ok::<Event, Infallible>(Event::default().data(json)), rx)),
|
Ok(bytes) => Some((Ok::<Event, Infallible>(Event::default().data(BASE64.encode(bytes))), rx)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("Failed to serialize SSE event: {}", e);
|
tracing::warn!("Failed to serialize SSE event (MessagePack): {}", e);
|
||||||
Some((
|
Some((
|
||||||
Ok::<Event, Infallible>(Event::default().comment("error")),
|
Ok::<Event, Infallible>(Event::default().comment("error")),
|
||||||
rx,
|
rx,
|
||||||
@@ -244,6 +243,11 @@ pub async fn sse_handler(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Sse::new(initial_stream.chain(update_stream))
|
let sse = Sse::new(initial_stream.chain(update_stream))
|
||||||
.keep_alive(axum::response::sse::KeepAlive::default())
|
.keep_alive(axum::response::sse::KeepAlive::default());
|
||||||
}
|
|
||||||
|
(
|
||||||
|
[("content-type", "text/event-stream")],
|
||||||
|
sse
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,3 +31,25 @@ serde-wasm-bindgen = "0.6.5"
|
|||||||
leptos-use = { version = "0.16", features = ["storage"] }
|
leptos-use = { version = "0.16", features = ["storage"] }
|
||||||
codee = "0.3"
|
codee = "0.3"
|
||||||
thiserror = "2.0"
|
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"
|
||||||
@@ -1,16 +1,161 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@config "./tailwind.config.js";
|
@config "./tailwind.config.js";
|
||||||
|
@source "../src/**/*.rs";
|
||||||
|
@source "/Users/bilal/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/leptos-shadcn-*/src/**/*.rs";
|
||||||
|
|
||||||
@plugin "daisyui" {
|
@theme {
|
||||||
themes:
|
/* Shadcn Colors */
|
||||||
light, dark, dim, nord, cupcake, dracula, cyberpunk, emerald, sunset,
|
--color-border: hsl(var(--border));
|
||||||
abyss;
|
--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 {
|
@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 {
|
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 {
|
:focus {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"daisyui": "^5.5.1-beta.2",
|
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.18"
|
"tailwindcss": "^4.1.18"
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -142,25 +142,21 @@ pub mod settings {
|
|||||||
|
|
||||||
pub mod push {
|
pub mod push {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::store::PushSubscriptionData;
|
|
||||||
|
|
||||||
pub async fn get_public_key() -> Result<String, ApiError> {
|
pub async fn get_public_key() -> Result<String, ApiError> {
|
||||||
let resp = Request::get(&format!("{}/push/public-key", base_url()))
|
shared::server_fns::push::get_public_key()
|
||||||
.send()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::Network)?;
|
.map_err(|e| ApiError::ServerFn(e.to_string()))
|
||||||
let key = resp.text().await.map_err(|_| ApiError::Network)?;
|
|
||||||
Ok(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn subscribe(req: &PushSubscriptionData) -> Result<(), ApiError> {
|
pub async fn subscribe(endpoint: &str, p256dh: &str, auth: &str) -> Result<(), ApiError> {
|
||||||
Request::post(&format!("{}/push/subscribe", base_url()))
|
shared::server_fns::push::subscribe_push(
|
||||||
.json(req)
|
endpoint.to_string(),
|
||||||
.map_err(|_| ApiError::Network)?
|
p256dh.to_string(),
|
||||||
.send()
|
auth.to_string(),
|
||||||
.await
|
)
|
||||||
.map_err(|_| ApiError::Network)?;
|
.await
|
||||||
Ok(())
|
.map_err(|e| ApiError::ServerFn(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
use crate::components::layout::protected::Protected;
|
use crate::components::layout::protected::Protected;
|
||||||
use crate::components::toast::ToastContainer;
|
use crate::components::toast::ToastContainer;
|
||||||
use crate::components::torrent::table::TorrentTable;
|
use crate::components::torrent::table::TorrentTable;
|
||||||
|
use crate::components::torrent::detail::TorrentDetail;
|
||||||
use crate::components::auth::login::Login;
|
use crate::components::auth::login::Login;
|
||||||
use crate::components::auth::setup::Setup;
|
use crate::components::auth::setup::Setup;
|
||||||
use crate::api;
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use leptos_router::components::{Router, Routes, Route};
|
use leptos_router::components::{Router, Routes, Route};
|
||||||
use leptos_router::hooks::use_navigate;
|
use leptos_router::hooks::use_navigate;
|
||||||
|
use leptos_shadcn_skeleton::Skeleton;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
@@ -22,9 +23,8 @@ pub fn App() -> impl IntoView {
|
|||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
log::info!("App initialization started...");
|
log::info!("App initialization started...");
|
||||||
|
|
||||||
let setup_res = api::setup::get_status().await;
|
// Check if setup is needed via Server Function
|
||||||
|
match shared::server_fns::auth::get_setup_status().await {
|
||||||
match setup_res {
|
|
||||||
Ok(status) => {
|
Ok(status) => {
|
||||||
if !status.completed {
|
if !status.completed {
|
||||||
log::info!("Setup not completed");
|
log::info!("Setup not completed");
|
||||||
@@ -36,21 +36,16 @@ pub fn App() -> impl IntoView {
|
|||||||
Err(e) => log::error!("Failed to get setup status: {:?}", e),
|
Err(e) => log::error!("Failed to get setup status: {:?}", e),
|
||||||
}
|
}
|
||||||
|
|
||||||
let auth_res = api::auth::check_auth().await;
|
// Check authentication via GetUser Server Function
|
||||||
|
match shared::server_fns::auth::get_user().await {
|
||||||
match auth_res {
|
Ok(Some(user_info)) => {
|
||||||
Ok(true) => {
|
log::info!("Authenticated as {}", user_info.username);
|
||||||
log::info!("Authenticated!");
|
if let Some(s) = store {
|
||||||
|
s.user.set(Some(user_info.username));
|
||||||
if let Ok(user_info) = api::auth::get_user().await {
|
|
||||||
if let Some(s) = store {
|
|
||||||
s.user.set(Some(user_info.username));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is_authenticated.1.set(true);
|
is_authenticated.1.set(true);
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
Ok(None) => {
|
||||||
log::info!("Not authenticated");
|
log::info!("Not authenticated");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -107,31 +102,70 @@ pub fn App() -> impl IntoView {
|
|||||||
} />
|
} />
|
||||||
|
|
||||||
<Route path=leptos_router::path!("/") view=move || {
|
<Route path=leptos_router::path!("/") view=move || {
|
||||||
|
let navigate = use_navigate();
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
if !is_loading.0.get() && needs_setup.0.get() {
|
if !is_loading.0.get() {
|
||||||
log::info!("Setup not completed, redirecting to setup");
|
if needs_setup.0.get() {
|
||||||
let navigate = use_navigate();
|
log::info!("Setup not completed, redirecting to setup");
|
||||||
navigate("/setup", Default::default());
|
navigate("/setup", Default::default());
|
||||||
} else if !is_loading.0.get() && !is_authenticated.0.get() {
|
} else if !is_authenticated.0.get() {
|
||||||
log::info!("Not authenticated, redirecting to login");
|
log::info!("Not authenticated, redirecting to login");
|
||||||
let navigate = use_navigate();
|
navigate("/login", Default::default());
|
||||||
navigate("/login", Default::default());
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Show when=move || !is_loading.0.get() fallback=|| view! {
|
<Show when=move || !is_loading.0.get() fallback=|| view! {
|
||||||
<div class="flex items-center justify-center h-screen bg-base-100">
|
<div class="flex h-screen bg-background">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
// 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>
|
</div>
|
||||||
}>
|
}.into_any()>
|
||||||
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
||||||
<Protected>
|
<Protected>
|
||||||
<TorrentTable />
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<TorrentTable />
|
||||||
|
</div>
|
||||||
|
<TorrentDetail />
|
||||||
|
</div>
|
||||||
</Protected>
|
</Protected>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}.into_any()
|
||||||
}/>
|
}/>
|
||||||
|
|
||||||
<Route path=leptos_router::path!("/settings") view=move || {
|
<Route path=leptos_router::path!("/settings") view=move || {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
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]
|
#[component]
|
||||||
pub fn Login() -> impl IntoView {
|
pub fn Login() -> impl IntoView {
|
||||||
let username = signal(String::new());
|
let username = signal(String::new());
|
||||||
let password = signal(String::new());
|
let password = signal(String::new());
|
||||||
let remember_me = signal(false);
|
|
||||||
let error = signal(Option::<String>::None);
|
let error = signal(Option::<String>::None);
|
||||||
let loading = signal(false);
|
let loading = signal(false);
|
||||||
|
|
||||||
@@ -17,19 +20,14 @@ pub fn Login() -> impl IntoView {
|
|||||||
|
|
||||||
let user = username.0.get();
|
let user = username.0.get();
|
||||||
let pass = password.0.get();
|
let pass = password.0.get();
|
||||||
let rem = remember_me.0.get();
|
|
||||||
|
|
||||||
log::info!("Attempting login for user: {}", user);
|
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::auth::login(&user, &pass, rem).await {
|
match shared::server_fns::auth::login(user, pass).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::info!("Login successful, redirecting...");
|
|
||||||
let window = web_sys::window().expect("window should exist");
|
let window = web_sys::window().expect("window should exist");
|
||||||
let _ = window.location().set_href("/");
|
let _ = window.location().set_href("/");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(_) => {
|
||||||
log::error!("Login failed: {:?}", e);
|
|
||||||
error.1.set(Some("Geçersiz kullanıcı adı veya şifre".to_string()));
|
error.1.set(Some("Geçersiz kullanıcı adı veya şifre".to_string()));
|
||||||
loading.1.set(false);
|
loading.1.set(false);
|
||||||
}
|
}
|
||||||
@@ -38,82 +36,64 @@ pub fn Login() -> impl IntoView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="flex items-center justify-center min-h-screen bg-base-200">
|
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4">
|
||||||
<div class="card w-full max-w-sm shadow-xl bg-base-100">
|
<Card class="w-full max-w-sm shadow-lg">
|
||||||
<div class="card-body">
|
<CardHeader class="pb-2 items-center">
|
||||||
<div class="flex flex-col items-center mb-6">
|
<div class="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-sm mb-4">
|
||||||
<div class="w-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-6 h-6">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-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="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" />
|
||||||
<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>
|
||||||
</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>
|
</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">
|
<form on:submit=handle_login class="space-y-4">
|
||||||
<div class="form-control">
|
<div class="space-y-2">
|
||||||
<label class="label">
|
<Label>"Kullanıcı Adı"</Label>
|
||||||
<span class="label-text">"Kullanıcı Adı"</span>
|
<Input
|
||||||
</label>
|
input_type="text"
|
||||||
<input
|
placeholder="Kullanıcı adınız"
|
||||||
type="text"
|
value=MaybeProp::derive(move || Some(username.0.get()))
|
||||||
placeholder="Kullanıcı adınız"
|
on_change=Callback::new(move |val: String| username.1.set(val))
|
||||||
class="input input-bordered w-full"
|
disabled=Signal::derive(move || loading.0.get())
|
||||||
prop:value=move || username.0.get()
|
|
||||||
on:input=move |ev| username.1.set(event_target_value(&ev))
|
|
||||||
disabled=move || loading.0.get()
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="space-y-2">
|
||||||
<label class="label">
|
<Label>"Şifre"</Label>
|
||||||
<span class="label-text">"Şifre"</span>
|
<Input
|
||||||
</label>
|
input_type="password"
|
||||||
<input
|
placeholder="******"
|
||||||
type="password"
|
value=MaybeProp::derive(move || Some(password.0.get()))
|
||||||
placeholder="******"
|
on_change=Callback::new(move |val: String| password.1.set(val))
|
||||||
class="input input-bordered w-full"
|
disabled=Signal::derive(move || loading.0.get())
|
||||||
prop:value=move || password.0.get()
|
|
||||||
on:input=move |ev| password.1.set(event_target_value(&ev))
|
|
||||||
disabled=move || loading.0.get()
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<Show when=move || error.0.get().is_some()>
|
||||||
<label class="label cursor-pointer justify-start gap-3">
|
<Alert variant=AlertVariant::Destructive>
|
||||||
<input
|
<AlertDescription>
|
||||||
type="checkbox"
|
{move || error.0.get().unwrap_or_default()}
|
||||||
class="checkbox checkbox-primary checkbox-sm"
|
</AlertDescription>
|
||||||
prop:checked=move || remember_me.0.get()
|
</Alert>
|
||||||
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>
|
</Show>
|
||||||
|
|
||||||
<div class="form-control mt-6">
|
<div class="pt-2">
|
||||||
<button
|
<Button
|
||||||
class="btn btn-primary w-full"
|
class="w-full"
|
||||||
type="submit"
|
disabled=Signal::derive(move || loading.0.get())
|
||||||
disabled=move || loading.0.get()
|
|
||||||
>
|
>
|
||||||
<Show when=move || loading.0.get() fallback=|| "Giriş Yap">
|
<Show when=move || loading.0.get() fallback=|| "Giriş Yap">
|
||||||
<span class="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>
|
</Show>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
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]
|
#[component]
|
||||||
pub fn Setup() -> impl IntoView {
|
pub fn Setup() -> impl IntoView {
|
||||||
@@ -32,7 +36,7 @@ pub fn Setup() -> impl IntoView {
|
|||||||
let user = username.0.get();
|
let user = username.0.get();
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::setup::setup(&user, &pass).await {
|
match shared::server_fns::auth::setup(user, pass).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::info!("Setup completed successfully, redirecting...");
|
log::info!("Setup completed successfully, redirecting...");
|
||||||
let window = web_sys::window().expect("window should exist");
|
let window = web_sys::window().expect("window should exist");
|
||||||
@@ -40,7 +44,7 @@ pub fn Setup() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Setup failed: {:?}", 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);
|
loading.1.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,83 +52,73 @@ pub fn Setup() -> impl IntoView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="flex items-center justify-center min-h-screen bg-base-200">
|
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4">
|
||||||
<div class="card w-full max-w-md shadow-xl bg-base-100">
|
<Card class="w-full max-w-md shadow-lg overflow-hidden">
|
||||||
<div class="card-body">
|
<CardHeader class="pb-2 items-center text-center">
|
||||||
<div class="flex flex-col items-center mb-6 text-center">
|
<div class="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-sm mb-4">
|
||||||
<div class="w-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-6 h-6">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-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" />
|
||||||
<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>
|
||||||
</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>
|
</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">
|
<form on:submit=handle_setup class="space-y-4">
|
||||||
<div class="form-control">
|
<div class="space-y-2">
|
||||||
<label class="label">
|
<Label>"Yönetici Kullanıcı Adı"</Label>
|
||||||
<span class="label-text">"Yönetici Kullanıcı Adı"</span>
|
<Input
|
||||||
</label>
|
input_type="text"
|
||||||
<input
|
placeholder="admin"
|
||||||
type="text"
|
value=MaybeProp::derive(move || Some(username.0.get()))
|
||||||
placeholder="admin"
|
on_change=Callback::new(move |val: String| username.1.set(val))
|
||||||
class="input input-bordered w-full"
|
disabled=Signal::derive(move || loading.0.get())
|
||||||
prop:value=move || username.0.get()
|
|
||||||
on:input=move |ev| username.1.set(event_target_value(&ev))
|
|
||||||
disabled=move || loading.0.get()
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="space-y-2">
|
||||||
<label class="label">
|
<Label>"Şifre"</Label>
|
||||||
<span class="label-text">"Şifre"</span>
|
<Input
|
||||||
</label>
|
input_type="password"
|
||||||
<input
|
placeholder="******"
|
||||||
type="password"
|
value=MaybeProp::derive(move || Some(password.0.get()))
|
||||||
placeholder="******"
|
on_change=Callback::new(move |val: String| password.1.set(val))
|
||||||
class="input input-bordered w-full"
|
disabled=Signal::derive(move || loading.0.get())
|
||||||
prop:value=move || password.0.get()
|
|
||||||
on:input=move |ev| password.1.set(event_target_value(&ev))
|
|
||||||
disabled=move || loading.0.get()
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="space-y-2">
|
||||||
<label class="label">
|
<Label>"Şifre Onay"</Label>
|
||||||
<span class="label-text">"Şifre Onay"</span>
|
<Input
|
||||||
</label>
|
input_type="password"
|
||||||
<input
|
placeholder="******"
|
||||||
type="password"
|
value=MaybeProp::derive(move || Some(confirm_password.0.get()))
|
||||||
placeholder="******"
|
on_change=Callback::new(move |val: String| confirm_password.1.set(val))
|
||||||
class="input input-bordered w-full"
|
disabled=Signal::derive(move || loading.0.get())
|
||||||
prop:value=move || confirm_password.0.get()
|
|
||||||
on:input=move |ev| confirm_password.1.set(event_target_value(&ev))
|
|
||||||
disabled=move || loading.0.get()
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when=move || error.0.get().is_some() fallback=|| ()>
|
<Show when=move || error.0.get().is_some() fallback=|| ()>
|
||||||
<div class="alert alert-error text-xs py-2 shadow-sm">
|
<Alert variant=AlertVariant::Destructive>
|
||||||
<span>{move || error.0.get().unwrap_or_default()}</span>
|
<AlertDescription>
|
||||||
</div>
|
<span>{move || error.0.get().unwrap_or_default()}</span>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="form-control mt-6">
|
<div class="pt-2">
|
||||||
<button
|
<Button
|
||||||
class="btn btn-primary w-full"
|
class="w-full"
|
||||||
type="submit"
|
disabled=Signal::derive(move || loading.0.get())
|
||||||
disabled=move || loading.0.get()
|
|
||||||
>
|
>
|
||||||
<Show when=move || loading.0.get() fallback=|| "Kurulumu Tamamla">
|
<Show when=move || loading.0.get() fallback=|| "Kurulumu Tamamla">
|
||||||
<span class="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>
|
</Show>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,158 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::html;
|
use web_sys::MouseEvent;
|
||||||
use leptos_use::on_click_outside;
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
fn handle_action(
|
// ── Kendi reaktif Context Menu implementasyonumuz ──
|
||||||
hash: String,
|
// leptos-shadcn-context-menu v0.8.1'de ContextMenuContent'te
|
||||||
action: &str,
|
// `if open.get()` statik kontrolü reaktif değil. Aşağıda
|
||||||
on_action: Callback<(String, String)>,
|
// `Show` bileşeni ile düzgün reaktif versiyon yer alıyor.
|
||||||
on_close: Callback<()>,
|
|
||||||
) {
|
|
||||||
log::info!("ContextMenu: Action '{}' for hash '{}'", action, hash);
|
|
||||||
on_action.run((action.to_string(), hash));
|
|
||||||
on_close.run(());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ContextMenu(
|
pub fn TorrentContextMenu(
|
||||||
position: (i32, i32),
|
children: Children,
|
||||||
torrent_hash: String,
|
torrent_hash: String,
|
||||||
on_close: Callback<()>,
|
|
||||||
on_action: Callback<(String, String)>,
|
on_action: Callback<(String, String)>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let container_ref = NodeRef::<html::Div>::new();
|
let hash = StoredValue::new(torrent_hash);
|
||||||
|
let on_action = StoredValue::new(on_action);
|
||||||
let _ = on_click_outside(container_ref, move |_| on_close.run(()));
|
|
||||||
|
|
||||||
let (x, y) = position;
|
let open = RwSignal::new(false);
|
||||||
|
let position = RwSignal::new((0i32, 0i32));
|
||||||
let hash1 = torrent_hash.clone();
|
|
||||||
let hash2 = torrent_hash.clone();
|
// Sağ tıklama handler
|
||||||
let hash3 = torrent_hash.clone();
|
let on_contextmenu = move |e: MouseEvent| {
|
||||||
let hash4 = torrent_hash.clone();
|
e.prevent_default();
|
||||||
let hash5 = torrent_hash;
|
e.stop_propagation();
|
||||||
|
position.set((e.client_x(), e.client_y()));
|
||||||
|
open.set(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Menü dışına tıklandığında kapanma
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if open.get() {
|
||||||
|
let cb = Closure::wrap(Box::new(move |_: MouseEvent| {
|
||||||
|
open.set(false);
|
||||||
|
}) as Box<dyn Fn(MouseEvent)>);
|
||||||
|
|
||||||
|
let window = web_sys::window().unwrap();
|
||||||
|
let document = window.document().unwrap();
|
||||||
|
let _ = document.add_event_listener_with_callback(
|
||||||
|
"click",
|
||||||
|
cb.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup: tek sefer dinleyici — click yakalandığında otomatik kapanıp listener kalıyor
|
||||||
|
// ama open=false olduğunda effect tekrar çalışmaz, böylece sorun yok.
|
||||||
|
cb.forget();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let menu_action = move |action: &'static str| {
|
||||||
|
open.set(false);
|
||||||
|
on_action.get_value().run((action.to_string(), hash.get_value()));
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<div
|
||||||
node_ref=container_ref
|
class="w-full"
|
||||||
class="fixed z-[100] min-w-[200px] animate-in fade-in zoom-in-95 duration-100"
|
on:contextmenu=on_contextmenu
|
||||||
style=format!("left: {}px; top: {}px;", x, y)
|
|
||||||
on:contextmenu=move |e| e.prevent_default()
|
|
||||||
>
|
>
|
||||||
<ul class="menu bg-base-200 shadow-xl rounded-box border border-base-300 p-1 gap-0.5">
|
{children()}
|
||||||
<li>
|
|
||||||
<button class="flex items-center gap-3 px-3 py-2 hover:bg-primary hover:text-primary-content rounded-lg transition-colors" on:click=move |_| {
|
|
||||||
handle_action(hash1.clone(), "start", on_action.clone(), on_close.clone());
|
|
||||||
}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
|
||||||
</svg>
|
|
||||||
<span>"Start"</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button class="flex items-center gap-3 px-3 py-2 hover:bg-primary hover:text-primary-content rounded-lg transition-colors" on:click=move |_| {
|
|
||||||
handle_action(hash2.clone(), "stop", on_action.clone(), on_close.clone());
|
|
||||||
}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
|
||||||
</svg>
|
|
||||||
<span>"Stop"</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button class="flex items-center gap-3 px-3 py-2 hover:bg-primary hover:text-primary-content rounded-lg transition-colors" on:click=move |_| {
|
|
||||||
handle_action(hash3.clone(), "recheck", on_action.clone(), on_close.clone());
|
|
||||||
}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
|
||||||
</svg>
|
|
||||||
<span>"Recheck"</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<div class="divider my-0.5 opacity-50"></div>
|
|
||||||
<li>
|
|
||||||
<button class="flex items-center gap-3 px-3 py-2 text-error hover:bg-error hover:text-error-content rounded-lg transition-colors" on:click=move |_| {
|
|
||||||
handle_action(hash4.clone(), "delete", on_action.clone(), on_close.clone());
|
|
||||||
}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.164h-2.34c-1.18 0-2.09.984-2.09 2.164v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
|
||||||
</svg>
|
|
||||||
<span>"Remove"</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button class="flex items-center gap-3 px-3 py-2 text-error hover:bg-error hover:text-error-content rounded-lg transition-colors" on:click=move |_| {
|
|
||||||
handle_action(hash5.clone(), "delete_with_data", on_action.clone(), on_close.clone());
|
|
||||||
}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
|
||||||
</svg>
|
|
||||||
<span>"Remove Data"</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when=move || open.get()>
|
||||||
|
{
|
||||||
|
let (x, y) = position.get();
|
||||||
|
// 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ı aç
|
||||||
|
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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,28 +5,48 @@ use crate::components::layout::statusbar::StatusBar;
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Protected(children: Children) -> impl IntoView {
|
pub fn Protected(children: Children) -> impl IntoView {
|
||||||
|
// Mobil menü durumu için bir sinyal oluşturuyoruz (RwSignal for easier passing)
|
||||||
|
let is_mobile_menu_open = RwSignal::new(false);
|
||||||
|
|
||||||
|
// Sinyali context olarak sağlıyoruz ki Toolbar ve Sidebar buna erişebilsin
|
||||||
|
provide_context(is_mobile_menu_open);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="drawer lg:drawer-open h-full w-full">
|
<div class="flex h-screen w-full overflow-hidden bg-background">
|
||||||
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
|
|
||||||
|
|
||||||
<div class="drawer-content flex flex-col h-full overflow-hidden bg-base-100">
|
// --- SIDEBAR (Desktop: Sabit, Mobil: Overlay) ---
|
||||||
|
<aside class=move || {
|
||||||
|
let base = "fixed inset-y-0 left-0 z-50 w-64 transform transition-transform duration-300 ease-in-out border-r border-border bg-card lg:relative lg:translate-x-0";
|
||||||
|
if is_mobile_menu_open.get() {
|
||||||
|
format!("{} translate-x-0", base)
|
||||||
|
} else {
|
||||||
|
format!("{} -translate-x-full", base)
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<Sidebar />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
// Mobil arka plan karartma (Overlay)
|
||||||
|
<Show when=move || is_mobile_menu_open.get()>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm lg:hidden"
|
||||||
|
on:click=move |_| is_mobile_menu_open.set(false)
|
||||||
|
></div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
// --- MAIN CONTENT AREA ---
|
||||||
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
// --- TOOLBAR (TOP) ---
|
// --- TOOLBAR (TOP) ---
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
|
|
||||||
// --- MAIN CONTENT ---
|
// --- MAIN CONTENT ---
|
||||||
<main class="flex-1 overflow-hidden relative">
|
<main class="flex-1 overflow-hidden relative bg-background">
|
||||||
{children()}
|
{children()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
// --- STATUS BAR (BOTTOM) ---
|
// --- STATUS BAR (BOTTOM) ---
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// --- SIDEBAR (DRAWER) ---
|
|
||||||
<div class="drawer-side z-[100]">
|
|
||||||
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
|
||||||
<Sidebar />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::wasm_bindgen::JsCast;
|
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use crate::api;
|
use leptos_shadcn_button::{Button, ButtonVariant, ButtonSize};
|
||||||
|
use leptos_shadcn_avatar::{Avatar, AvatarFallback};
|
||||||
|
use leptos_shadcn_separator::Separator;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Sidebar() -> impl IntoView {
|
pub fn Sidebar() -> impl IntoView {
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
|
let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
|
||||||
|
|
||||||
let total_count = move || store.torrents.with(|map| map.len());
|
let total_count = move || store.torrents.with(|map| map.len());
|
||||||
let downloading_count = move || {
|
let downloading_count = move || {
|
||||||
@@ -50,35 +52,12 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let close_drawer = move || {
|
|
||||||
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| {
|
let set_filter = move |f: crate::store::FilterStatus| {
|
||||||
store.filter.set(f);
|
store.filter.set(f);
|
||||||
close_drawer();
|
is_mobile_menu_open.set(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
let filter_class = move |f: crate::store::FilterStatus| {
|
let is_active = move |f: crate::store::FilterStatus| store.filter.get() == f;
|
||||||
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 username = move || {
|
let username = move || {
|
||||||
store.user.get().unwrap_or_else(|| "User".to_string())
|
store.user.get().unwrap_or_else(|| "User".to_string())
|
||||||
@@ -89,89 +68,126 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
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="w-full h-full flex flex-col bg-card" style="padding-top: env(safe-area-inset-top);">
|
||||||
<div class="p-2 flex-1 overflow-y-auto">
|
<div class="p-4 flex-1 overflow-y-auto">
|
||||||
<ul class="menu w-full rounded-box gap-1">
|
<div class="mb-4 px-2 text-lg font-semibold tracking-tight text-foreground">
|
||||||
<li class="menu-title text-primary uppercase font-bold px-4">"Filters"</li>
|
"VibeTorrent"
|
||||||
<li>
|
</div>
|
||||||
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::All))} on:click=move |_| set_filter(crate::store::FilterStatus::All)>
|
<div class="space-y-1">
|
||||||
<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">
|
<h4 class="mb-1 rounded-md px-2 py-1 text-sm font-semibold text-muted-foreground">"Filters"</h4>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
|
||||||
</svg>
|
<Button
|
||||||
"All"
|
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::All) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
||||||
<span class="badge badge-sm badge-ghost ml-auto">{total_count}</span>
|
size=ButtonSize::Sm
|
||||||
</button>
|
class="w-full justify-start gap-2"
|
||||||
</li>
|
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::All))
|
||||||
<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-4 h-4">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
</svg>
|
||||||
</svg>
|
"All"
|
||||||
"Downloading"
|
<span class="ml-auto text-xs font-mono opacity-70">{total_count}</span>
|
||||||
<span class="badge badge-sm badge-ghost ml-auto">{downloading_count}</span>
|
</Button>
|
||||||
</button>
|
|
||||||
</li>
|
<Button
|
||||||
<li>
|
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Downloading) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
||||||
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Seeding))} on:click=move |_| set_filter(crate::store::FilterStatus::Seeding)>
|
size=ButtonSize::Sm
|
||||||
<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">
|
class="w-full justify-start gap-2"
|
||||||
<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" />
|
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Downloading))
|
||||||
</svg>
|
>
|
||||||
"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">
|
||||||
<span class="badge badge-sm badge-ghost ml-auto">{seeding_count}</span>
|
<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" />
|
||||||
</button>
|
</svg>
|
||||||
</li>
|
"Downloading"
|
||||||
<li>
|
<span class="ml-auto text-xs font-mono opacity-70">{downloading_count}</span>
|
||||||
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Completed))} on:click=move |_| set_filter(crate::store::FilterStatus::Completed)>
|
</Button>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-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" />
|
<Button
|
||||||
</svg>
|
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Seeding) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
||||||
"Completed"
|
size=ButtonSize::Sm
|
||||||
<span class="badge badge-sm badge-ghost ml-auto">{completed_count}</span>
|
class="w-full justify-start gap-2"
|
||||||
</button>
|
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Seeding))
|
||||||
</li>
|
>
|
||||||
<li>
|
<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">
|
||||||
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Paused))} on:click=move |_| set_filter(crate::store::FilterStatus::Paused)>
|
<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 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">
|
</svg>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
"Seeding"
|
||||||
</svg>
|
<span class="ml-auto text-xs font-mono opacity-70">{seeding_count}</span>
|
||||||
"Paused"
|
</Button>
|
||||||
<span class="badge badge-sm badge-ghost ml-auto">{paused_count}</span>
|
|
||||||
</button>
|
<Button
|
||||||
</li>
|
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Completed) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
||||||
<li>
|
size=ButtonSize::Sm
|
||||||
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Inactive))} on:click=move |_| set_filter(crate::store::FilterStatus::Inactive)>
|
class="w-full justify-start gap-2"
|
||||||
<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">
|
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Completed))
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
>
|
||||||
</svg>
|
<svg 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">
|
||||||
"Inactive"
|
<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" />
|
||||||
<span class="badge badge-sm badge-ghost ml-auto">{inactive_count}</span>
|
</svg>
|
||||||
</button>
|
"Completed"
|
||||||
</li>
|
<span class="ml-auto text-xs font-mono opacity-70">{completed_count}</span>
|
||||||
</ul>
|
</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>
|
||||||
|
|
||||||
<div class="p-4 border-t border-base-300 bg-base-200/50">
|
<Separator />
|
||||||
|
|
||||||
|
<div class="p-4 bg-card">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="avatar">
|
<Avatar class="h-8 w-8">
|
||||||
<div class="w-8 rounded-full bg-neutral text-neutral-content ring ring-primary ring-offset-base-100 ring-offset-1">
|
<AvatarFallback class="bg-primary text-primary-foreground text-xs font-medium">
|
||||||
<span class="text-sm font-bold flex items-center justify-center h-full">{first_letter}</span>
|
{first_letter}
|
||||||
</div>
|
</AvatarFallback>
|
||||||
</div>
|
</Avatar>
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden">
|
||||||
<div class="font-bold text-sm truncate">{username}</div>
|
<div class="font-medium text-sm truncate text-foreground">{username}</div>
|
||||||
<div class="text-[10px] text-base-content/60 truncate">"Online"</div>
|
<div class="text-[10px] text-muted-foreground truncate">"Online"</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/10"
|
variant=ButtonVariant::Ghost
|
||||||
title="Logout"
|
size=ButtonSize::Icon
|
||||||
on:click=handle_logout
|
class="text-destructive h-8 w-8"
|
||||||
|
on_click=Callback::new(move |()| {
|
||||||
|
spawn_local(async move {
|
||||||
|
if shared::server_fns::auth::logout().await.is_ok() {
|
||||||
|
let window = web_sys::window().expect("window should exist");
|
||||||
|
let _ = window.location().set_href("/login");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ pub fn StatusBar() -> impl IntoView {
|
|||||||
let theme = current_theme.get().to_lowercase();
|
let theme = current_theme.get().to_lowercase();
|
||||||
if let Some(doc) = document().document_element() {
|
if let Some(doc) = document().document_element() {
|
||||||
let _ = doc.set_attribute("data-theme", &theme);
|
let _ = doc.set_attribute("data-theme", &theme);
|
||||||
|
// Also set class for Shadcn dark mode support
|
||||||
|
if theme == "dark" || theme == "dracula" || theme == "dim" || theme == "abyss" {
|
||||||
|
let _ = doc.class_list().add_1("dark");
|
||||||
|
} else {
|
||||||
|
let _ = doc.class_list().remove_1("dark");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,11 +100,11 @@ pub fn StatusBar() -> impl IntoView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
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 ---
|
// --- DOWNLOAD SPEED DROPDOWN ---
|
||||||
<details class="dropdown dropdown-top" node_ref=down_details_ref>
|
<details class="group relative" 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">
|
<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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -110,37 +116,44 @@ pub fn StatusBar() -> impl IntoView {
|
|||||||
</Show>
|
</Show>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<ul class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300">
|
<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 || {
|
limits.clone().into_iter().map(|(val, label)| {
|
||||||
let current = stats.get().down_limit.unwrap_or(0);
|
let is_active = move || {
|
||||||
(current - val).abs() < 1024
|
let current = stats.get().down_limit.unwrap_or(0);
|
||||||
};
|
(current - val).abs() < 1024
|
||||||
view! {
|
};
|
||||||
<li>
|
view! {
|
||||||
<button
|
<li>
|
||||||
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
|
<button
|
||||||
on:click=move |_| {
|
class=move || {
|
||||||
set_limit("down", val);
|
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";
|
||||||
close_details(down_details_ref);
|
if is_active() { format!("{} bg-accent text-accent-foreground font-medium", base) } else { base.to_string() }
|
||||||
}
|
}
|
||||||
>
|
on:click=move |_| {
|
||||||
{label}
|
set_limit("down", val);
|
||||||
<Show when=is_active fallback=|| ()>
|
close_details(down_details_ref);
|
||||||
<span>"✓"</span>
|
}
|
||||||
</Show>
|
>
|
||||||
</button>
|
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
</li>
|
<Show when=is_active fallback=|| ()>
|
||||||
}
|
<span>"✓"</span>
|
||||||
}).collect::<Vec<_>>()
|
</Show>
|
||||||
}
|
</span>
|
||||||
</ul>
|
{label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
// --- UPLOAD SPEED DROPDOWN ---
|
// --- UPLOAD SPEED DROPDOWN ---
|
||||||
<details class="dropdown dropdown-top" node_ref=up_details_ref>
|
<details class="group relative" 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">
|
<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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -152,114 +165,94 @@ pub fn StatusBar() -> impl IntoView {
|
|||||||
</Show>
|
</Show>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<ul class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300">
|
<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().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">
|
|
||||||
{
|
{
|
||||||
let themes = vec![
|
limits.clone().into_iter().map(|(val, label)| {
|
||||||
"light", "dark", "dim", "nord", "cupcake", "dracula", "cyberpunk", "emerald", "sunset", "abyss"
|
let is_active = move || {
|
||||||
];
|
let current = stats.get().up_limit.unwrap_or(0);
|
||||||
themes.into_iter().map(|theme| {
|
(current - val).abs() < 1024
|
||||||
let theme_name = theme.to_string();
|
};
|
||||||
let theme_name_for_class = theme_name.clone();
|
|
||||||
let theme_name_for_onclick = theme_name.clone();
|
|
||||||
view! {
|
view! {
|
||||||
<li>
|
<li>
|
||||||
<button
|
<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 |_| {
|
on:click=move |_| {
|
||||||
set_current_theme.set(theme_name_for_onclick.clone());
|
set_limit("up", val);
|
||||||
close_details(theme_details_ref);
|
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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
}).collect::<Vec<_>>()
|
}).collect::<Vec<_>>()
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center gap-4">
|
||||||
|
<details class="group relative" node_ref=theme_details_ref>
|
||||||
|
<summary 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 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>
|
||||||
|
|
||||||
|
<div class="absolute bottom-full right-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 max-h-96 overflow-y-auto">
|
||||||
|
<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();
|
||||||
|
let is_active = move || current_theme.get() == theme_name_for_class;
|
||||||
|
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 capitalize";
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<Show when=is_active.clone() fallback=|| ()>
|
||||||
|
<span>"✓"</span>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
{theme_name}
|
||||||
|
</button> </li>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<button
|
<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"
|
title="Settings & Notification Permissions"
|
||||||
on:click=move |_| {
|
on:click=move |_| {
|
||||||
// Request push notification permission when settings button is clicked
|
// Request push notification permission
|
||||||
leptos::task::spawn_local(async {
|
leptos::task::spawn_local(async {
|
||||||
log::info!("Settings button clicked - requesting push notification permission");
|
// ... existing logic ...
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
crate::store::subscribe_to_push_notifications().await;
|
crate::store::subscribe_to_push_notifications().await;
|
||||||
|
// ... existing logic ...
|
||||||
// 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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,59 +1,61 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use leptos_shadcn_input::Input;
|
||||||
|
use leptos_shadcn_button::{Button, ButtonVariant, ButtonSize};
|
||||||
use crate::components::torrent::add_torrent::AddTorrentDialog;
|
use crate::components::torrent::add_torrent::AddTorrentDialog;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Toolbar() -> impl IntoView {
|
pub fn Toolbar() -> impl IntoView {
|
||||||
let show_add_modal = signal(false);
|
let show_add_modal = signal(false);
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
|
let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="navbar min-h-14 h-auto bg-base-100 p-0" style="padding-top: env(safe-area-inset-top);">
|
<div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);">
|
||||||
<div class="navbar-start gap-4 px-4">
|
// Sol kısım: Menü butonu + Add Torrent
|
||||||
<label for="my-drawer" class="btn btn-square btn-ghost lg:hidden drawer-button">
|
<div class="flex items-center gap-3">
|
||||||
<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>
|
// Mobile Menu Trigger
|
||||||
</label>
|
<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
|
||||||
<button
|
class="gap-2 shadow"
|
||||||
class="btn btn-primary btn-sm md:btn-md gap-2 shadow-md hover:shadow-primary/20 transition-all"
|
on_click=Callback::new(move |()| show_add_modal.1.set(true))
|
||||||
on:click=move |_| show_add_modal.1.set(true)
|
>
|
||||||
>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 md:w-5 md:h-5">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 md:w-5 md:h-5">
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
</svg>
|
||||||
</svg>
|
<span class="hidden sm:inline">"Add Torrent"</span>
|
||||||
<span class="hidden sm:inline">"Add Torrent"</span>
|
<span class="sm:hidden">"Add"</span>
|
||||||
<span class="sm:hidden">"Add"</span>
|
</Button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar-center hidden md:flex">
|
// Sağ kısım: Search kutusu
|
||||||
<div class="join shadow-sm border border-base-200">
|
<div class="flex flex-1 items-center justify-end gap-2">
|
||||||
<div class="relative">
|
<div class="hidden md:flex items-center gap-2 w-full max-w-xs">
|
||||||
<input
|
<div class="relative flex-1">
|
||||||
type="text"
|
<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">
|
||||||
placeholder="Search..."
|
<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" />
|
||||||
class="input input-sm input-bordered join-item w-full max-w-xs focus:outline-none"
|
</svg>
|
||||||
prop:value=move || store.search_query.get()
|
<Input
|
||||||
on:input=move |ev| store.search_query.set(event_target_value(&ev))
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar-end px-4 gap-2">
|
<Show when=move || show_add_modal.0.get()>
|
||||||
<Show when=move || show_add_modal.0.get()>
|
<AddTorrentDialog on_close=Callback::new(move |()| show_add_modal.1.set(false)) />
|
||||||
<AddTorrentDialog on_close=Callback::new(move |()| show_add_modal.1.set(false)) />
|
</Show>
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,42 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use shared::NotificationLevel;
|
use shared::NotificationLevel;
|
||||||
|
use leptos_shadcn_alert::{Alert, AlertVariant};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Toast Components - DaisyUI Alert Style
|
// Toast Components - Using ShadCN Alert
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Returns the DaisyUI alert class for the notification level
|
fn level_to_variant(level: &NotificationLevel) -> AlertVariant {
|
||||||
fn get_alert_class(level: &NotificationLevel) -> &'static str {
|
|
||||||
match level {
|
match level {
|
||||||
NotificationLevel::Info => "alert alert-info",
|
NotificationLevel::Info => AlertVariant::Default,
|
||||||
NotificationLevel::Success => "alert alert-success",
|
NotificationLevel::Success => AlertVariant::Success,
|
||||||
NotificationLevel::Warning => "alert alert-warning",
|
NotificationLevel::Warning => AlertVariant::Warning,
|
||||||
NotificationLevel::Error => "alert alert-error",
|
NotificationLevel::Error => AlertVariant::Destructive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn level_icon(level: &NotificationLevel) -> impl IntoView {
|
||||||
|
match level {
|
||||||
|
NotificationLevel::Info => view! {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||||
|
</svg>
|
||||||
|
}.into_any(),
|
||||||
|
NotificationLevel::Success => view! {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
|
||||||
|
<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>
|
||||||
|
}.into_any(),
|
||||||
|
NotificationLevel::Warning => view! {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
}.into_any(),
|
||||||
|
NotificationLevel::Error => view! {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
}.into_any(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,37 +46,16 @@ fn ToastItem(
|
|||||||
level: NotificationLevel,
|
level: NotificationLevel,
|
||||||
message: String,
|
message: String,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let alert_class = get_alert_class(&level);
|
let variant = level_to_variant(&level);
|
||||||
|
let icon = level_icon(&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! {
|
view! {
|
||||||
<div class=alert_class>
|
<Alert variant=variant class="pointer-events-auto shadow-lg">
|
||||||
{icon_svg}
|
<div class="flex items-center gap-3">
|
||||||
<span>{message}</span>
|
{icon}
|
||||||
</div>
|
<div class="text-sm font-medium">{message}</div>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +67,7 @@ pub fn ToastContainer() -> impl IntoView {
|
|||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<div
|
||||||
class="toast toast-end toast-bottom"
|
class="fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px] gap-2"
|
||||||
style="position: fixed; bottom: 20px; right: 20px; z-index: 99999;"
|
|
||||||
>
|
>
|
||||||
<For
|
<For
|
||||||
each=move || notifications.get()
|
each=move || notifications.get()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::html;
|
|
||||||
use leptos::task::spawn_local;
|
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::store::TorrentStore;
|
||||||
use crate::api;
|
use crate::api;
|
||||||
|
|
||||||
@@ -11,17 +13,10 @@ pub fn AddTorrentDialog(
|
|||||||
let store = use_context::<TorrentStore>().expect("TorrentStore not provided");
|
let store = use_context::<TorrentStore>().expect("TorrentStore not provided");
|
||||||
let notifications = store.notifications;
|
let notifications = store.notifications;
|
||||||
|
|
||||||
let dialog_ref = NodeRef::<html::Dialog>::new();
|
|
||||||
let uri = signal(String::new());
|
let uri = signal(String::new());
|
||||||
let is_loading = signal(false);
|
let is_loading = signal(false);
|
||||||
let error_msg = signal(Option::<String>::None);
|
let error_msg = signal(Option::<String>::None);
|
||||||
|
|
||||||
Effect::new(move |_| {
|
|
||||||
if let Some(dialog) = dialog_ref.get() {
|
|
||||||
let _ = dialog.show_modal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let handle_submit = move |ev: web_sys::SubmitEvent| {
|
let handle_submit = move |ev: web_sys::SubmitEvent| {
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
let uri_val = uri.0.get();
|
let uri_val = uri.0.get();
|
||||||
@@ -44,9 +39,6 @@ pub fn AddTorrentDialog(
|
|||||||
shared::NotificationLevel::Success,
|
shared::NotificationLevel::Success,
|
||||||
"Torrent başarıyla eklendi"
|
"Torrent başarıyla eklendi"
|
||||||
);
|
);
|
||||||
if let Some(dialog) = dialog_ref.get() {
|
|
||||||
dialog.close();
|
|
||||||
}
|
|
||||||
on_close.run(());
|
on_close.run(());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -58,51 +50,76 @@ pub fn AddTorrentDialog(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let handle_cancel = move |_| {
|
let handle_backdrop = {
|
||||||
if let Some(dialog) = dialog_ref.get() {
|
let on_close = on_close.clone();
|
||||||
dialog.close();
|
move |e: web_sys::MouseEvent| {
|
||||||
|
e.stop_propagation();
|
||||||
|
on_close.run(());
|
||||||
}
|
}
|
||||||
on_close.run(());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<dialog node_ref=dialog_ref class="modal modal-bottom sm:modal-middle">
|
// Backdrop overlay
|
||||||
<div class="modal-box">
|
<div
|
||||||
<h3 class="font-bold text-lg">"Add Torrent"</h3>
|
class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
<p class="py-4 text-sm opacity-70">"Enter a Magnet link or a .torrent file URL."</p>
|
on:click=handle_backdrop
|
||||||
|
/>
|
||||||
<form on:submit=handle_submit>
|
// Dialog panel
|
||||||
<div class="form-control w-full">
|
<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]">
|
||||||
<input
|
// Header
|
||||||
type="text"
|
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
|
||||||
placeholder="magnet:?xt=urn:btih:..."
|
<h2 class="text-lg font-semibold leading-none tracking-tight">"Add Torrent"</h2>
|
||||||
class="input input-bordered w-full"
|
<p class="text-sm text-muted-foreground">"Enter a Magnet link or a .torrent file URL."</p>
|
||||||
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>
|
|
||||||
})}
|
|
||||||
</div>
|
</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>
|
</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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
156
frontend/src/components/torrent/detail.rs
Normal file
156
frontend/src/components/torrent/detail.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_shadcn_tabs::{Tabs, TabsList, TabsTrigger, TabsContent};
|
||||||
|
|
||||||
|
fn format_bytes(bytes: i64) -> String {
|
||||||
|
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
|
if bytes < 1024 { return format!("{} B", bytes); }
|
||||||
|
let i = (bytes as f64).log2().div_euclid(10.0) as usize;
|
||||||
|
format!("{:.1} {}", (bytes as f64) / 1024_f64.powi(i as i32), UNITS[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_speed(bytes_per_sec: i64) -> String {
|
||||||
|
if bytes_per_sec == 0 { return "0 B/s".to_string(); }
|
||||||
|
format!("{}/s", format_bytes(bytes_per_sec))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_date(timestamp: i64) -> String {
|
||||||
|
if timestamp <= 0 { return "N/A".to_string(); }
|
||||||
|
let dt = chrono::DateTime::from_timestamp(timestamp, 0);
|
||||||
|
match dt { Some(dt) => dt.format("%d/%m/%Y %H:%M").to_string(), None => "N/A".to_string() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_duration(seconds: i64) -> String {
|
||||||
|
if seconds <= 0 { return "∞".to_string(); }
|
||||||
|
let days = seconds / 86400;
|
||||||
|
let hours = (seconds % 86400) / 3600;
|
||||||
|
let minutes = (seconds % 3600) / 60;
|
||||||
|
let secs = seconds % 60;
|
||||||
|
if days > 0 { format!("{}d {}h", days, hours) }
|
||||||
|
else if hours > 0 { format!("{}h {}m", hours, minutes) }
|
||||||
|
else if minutes > 0 { format!("{}m {}s", minutes, secs) }
|
||||||
|
else { format!("{}s", secs) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TorrentDetail() -> impl IntoView {
|
||||||
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
|
|
||||||
|
let torrent = Memo::new(move |_| {
|
||||||
|
let hash = store.selected_torrent.get()?;
|
||||||
|
store.torrents.with(|map| map.get(&hash).cloned())
|
||||||
|
});
|
||||||
|
|
||||||
|
let close = move |_| {
|
||||||
|
store.selected_torrent.set(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Show when=move || torrent.get().is_some()>
|
||||||
|
{move || {
|
||||||
|
let t = torrent.get().unwrap();
|
||||||
|
let name = t.name.clone();
|
||||||
|
let status_color = match t.status {
|
||||||
|
shared::TorrentStatus::Seeding => "text-green-500",
|
||||||
|
shared::TorrentStatus::Downloading => "text-blue-500",
|
||||||
|
shared::TorrentStatus::Paused => "text-yellow-500",
|
||||||
|
shared::TorrentStatus::Error => "text-red-500",
|
||||||
|
_ => "text-muted-foreground",
|
||||||
|
};
|
||||||
|
let status_text = format!("{:?}", t.status);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="border-t border-border bg-card flex flex-col" style="height: 280px; min-height: 200px;">
|
||||||
|
// Header
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-border bg-muted/30">
|
||||||
|
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<h3 class="text-sm font-semibold truncate">{name}</h3>
|
||||||
|
<span class={format!("text-xs font-medium {}", status_color)}>{status_text}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center justify-center rounded-md text-sm font-medium hover:bg-accent hover:text-accent-foreground h-7 w-7 text-muted-foreground shrink-0"
|
||||||
|
on:click=close
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
<Tabs default_value="general" class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div class="px-4 pt-2">
|
||||||
|
<TabsList class="w-full">
|
||||||
|
<TabsTrigger value="general">"General"</TabsTrigger>
|
||||||
|
<TabsTrigger value="transfer">"Transfer"</TabsTrigger>
|
||||||
|
<TabsTrigger value="files">"Files"</TabsTrigger>
|
||||||
|
<TabsTrigger value="peers">"Peers"</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="general" class="flex-1 overflow-y-auto px-4 pb-3">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<DetailItem label="Size" value=format_bytes(t.size) />
|
||||||
|
<DetailItem label="Downloaded" value=format_bytes(t.completed) />
|
||||||
|
<DetailItem label="Progress" value=format!("{:.1}%", t.percent_complete) />
|
||||||
|
<DetailItem label="Added" value=format_date(t.added_date) />
|
||||||
|
<DetailItem label="Hash" value={
|
||||||
|
let hash = store.selected_torrent.get().unwrap_or_default();
|
||||||
|
format!("{}…", &hash[..std::cmp::min(16, hash.len())])
|
||||||
|
} />
|
||||||
|
<DetailItem label="Label" value=t.label.clone().unwrap_or_else(|| "—".to_string()) />
|
||||||
|
<DetailItem label="Error" value={
|
||||||
|
if t.error_message.is_empty() { "None".to_string() } else { t.error_message.clone() }
|
||||||
|
} />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="transfer" class="flex-1 overflow-y-auto px-4 pb-3">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<DetailItem label="Download Speed" value=format_speed(t.down_rate) />
|
||||||
|
<DetailItem label="Upload Speed" value=format_speed(t.up_rate) />
|
||||||
|
<DetailItem label="ETA" value=format_duration(t.eta) />
|
||||||
|
<DetailItem label="Downloaded" value=format_bytes(t.completed) />
|
||||||
|
<DetailItem label="Total Size" value=format_bytes(t.size) />
|
||||||
|
<DetailItem label="Remaining" value=format_bytes(t.size - t.completed) />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="files" class="flex-1 overflow-y-auto px-4 pb-3">
|
||||||
|
<div class="text-sm text-muted-foreground flex items-center gap-2 py-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
||||||
|
</svg>
|
||||||
|
"File list will be available when file API is connected."
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="peers" class="flex-1 overflow-y-auto px-4 pb-3">
|
||||||
|
<div class="text-sm text-muted-foreground flex items-center gap-2 py-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||||
|
</svg>
|
||||||
|
"Peer list will be available when peer API is connected."
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn DetailItem(
|
||||||
|
#[prop(into)] label: String,
|
||||||
|
#[prop(into)] value: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let title = value.clone();
|
||||||
|
view! {
|
||||||
|
<div class="flex flex-col gap-0.5 py-1">
|
||||||
|
<span class="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{label}</span>
|
||||||
|
<span class="text-foreground font-mono text-xs truncate" title=title>{value}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod table;
|
pub mod table;
|
||||||
pub mod add_torrent;
|
pub mod add_torrent;
|
||||||
|
pub mod detail;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::html;
|
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use leptos_use::use_timeout_fn;
|
|
||||||
use crate::store::{get_action_messages, show_toast_with_signal};
|
use crate::store::{get_action_messages, show_toast_with_signal};
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use shared::NotificationLevel;
|
use shared::NotificationLevel;
|
||||||
|
use crate::components::context_menu::TorrentContextMenu;
|
||||||
|
use leptos_shadcn_card::{Card, CardHeader, CardTitle, CardContent};
|
||||||
|
|
||||||
fn format_bytes(bytes: i64) -> String {
|
fn format_bytes(bytes: i64) -> String {
|
||||||
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
@@ -50,15 +50,13 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
let sort_col = signal(SortColumn::AddedDate);
|
let sort_col = signal(SortColumn::AddedDate);
|
||||||
let sort_dir = signal(SortDirection::Descending);
|
let sort_dir = signal(SortDirection::Descending);
|
||||||
|
|
||||||
let filtered_hashes = move || {
|
let filtered_hashes = Memo::new(move |_| {
|
||||||
let torrents_map = store.torrents.get();
|
let torrents_map = store.torrents.get();
|
||||||
log::debug!("TorrentTable: store.torrents has {} entries", torrents_map.len());
|
|
||||||
|
|
||||||
let filter = store.filter.get();
|
let filter = store.filter.get();
|
||||||
let search = store.search_query.get();
|
let search = store.search_query.get();
|
||||||
let search_lower = search.to_lowercase();
|
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 {
|
let matches_filter = match filter {
|
||||||
crate::store::FilterStatus::All => true,
|
crate::store::FilterStatus::All => true,
|
||||||
crate::store::FilterStatus::Downloading => t.status == shared::TorrentStatus::Downloading,
|
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) };
|
let matches_search = if search_lower.is_empty() { true } else { t.name.to_lowercase().contains(&search_lower) };
|
||||||
matches_filter && matches_search
|
matches_filter && matches_search
|
||||||
}).collect();
|
}).cloned().collect();
|
||||||
|
|
||||||
log::debug!("TorrentTable: {} torrents after filtering", torrents.len());
|
|
||||||
|
|
||||||
torrents.sort_by(|a, b| {
|
torrents.sort_by(|a, b| {
|
||||||
let col = sort_col.0.get();
|
let col = sort_col.0.get();
|
||||||
@@ -94,7 +90,7 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
if dir == SortDirection::Descending { cmp.reverse() } else { cmp }
|
if dir == SortDirection::Descending { cmp.reverse() } else { cmp }
|
||||||
});
|
});
|
||||||
torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
|
torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
|
||||||
};
|
});
|
||||||
|
|
||||||
let handle_sort = move |col: SortColumn| {
|
let handle_sort = move |col: SortColumn| {
|
||||||
if sort_col.0.get() == col {
|
if sort_col.0.get() == col {
|
||||||
@@ -107,8 +103,6 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let sort_details_ref = NodeRef::<html::Details>::new();
|
|
||||||
|
|
||||||
let sort_arrow = move |col: SortColumn| {
|
let sort_arrow = move |col: SortColumn| {
|
||||||
if sort_col.0.get() == col {
|
if sort_col.0.get() == col {
|
||||||
match sort_dir.0.get() {
|
match sort_dir.0.get() {
|
||||||
@@ -118,18 +112,7 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
} else { view! { <span class="ml-1 text-xs opacity-0 group-hover:opacity-50">"▲"</span> }.into_any() }
|
} else { view! { <span class="ml-1 text-xs opacity-0 group-hover:opacity-50">"▲"</span> }.into_any() }
|
||||||
};
|
};
|
||||||
|
|
||||||
let selected_hash = signal(Option::<String>::None);
|
let on_action = Callback::new(move |(action, hash): (String, String)| {
|
||||||
let menu_visible = signal(false);
|
|
||||||
let menu_position = signal((0, 0));
|
|
||||||
|
|
||||||
let handle_context_menu = move |e: web_sys::MouseEvent, hash: String| {
|
|
||||||
e.prevent_default();
|
|
||||||
menu_position.1.set((e.client_x(), e.client_y()));
|
|
||||||
selected_hash.1.set(Some(hash));
|
|
||||||
menu_visible.1.set(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_action = move |(action, hash): (String, String)| {
|
|
||||||
let (success_msg_str, error_msg_str): (&'static str, &'static str) = get_action_messages(&action);
|
let (success_msg_str, error_msg_str): (&'static str, &'static str) = get_action_messages(&action);
|
||||||
let success_msg = success_msg_str.to_string();
|
let success_msg = success_msg_str.to_string();
|
||||||
let error_msg = error_msg_str.to_string();
|
let error_msg = error_msg_str.to_string();
|
||||||
@@ -147,87 +130,74 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
Err(e) => show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: {:?}", error_msg, e)),
|
Err(e) => show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: {:?}", error_msg, e)),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="overflow-x-auto h-full bg-base-100 relative">
|
<div class="h-full bg-background relative flex flex-col overflow-hidden">
|
||||||
<div class="hidden md:block h-full overflow-x-auto">
|
// --- DESKTOP VIEW ---
|
||||||
<table class="table table-sm table-pin-rows w-full max-w-full whitespace-nowrap">
|
<div class="hidden md:flex flex-col h-full overflow-hidden">
|
||||||
<thead>
|
// Header
|
||||||
<tr class="text-xs uppercase text-base-content/60 border-b border-base-200">
|
<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">
|
||||||
<th class="cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
|
<div class="flex-1 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Name)>
|
||||||
<div class="flex items-center">"Name" {move || sort_arrow(SortColumn::Name)}</div>
|
"Name" {move || sort_arrow(SortColumn::Name)}
|
||||||
</th>
|
</div>
|
||||||
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::Size)>
|
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Size)>
|
||||||
<div class="flex items-center">"Size" {move || sort_arrow(SortColumn::Size)}</div>
|
"Size" {move || sort_arrow(SortColumn::Size)}
|
||||||
</th>
|
</div>
|
||||||
<th class="w-48 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::Progress)>
|
<div class="w-48 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Progress)>
|
||||||
<div class="flex items-center">"Progress" {move || sort_arrow(SortColumn::Progress)}</div>
|
"Progress" {move || sort_arrow(SortColumn::Progress)}
|
||||||
</th>
|
</div>
|
||||||
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::Status)>
|
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Status)>
|
||||||
<div class="flex items-center">"Status" {move || sort_arrow(SortColumn::Status)}</div>
|
"Status" {move || sort_arrow(SortColumn::Status)}
|
||||||
</th>
|
</div>
|
||||||
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
||||||
<div class="flex items-center">"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}</div>
|
"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}
|
||||||
</th>
|
</div>
|
||||||
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
||||||
<div class="flex items-center">"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}</div>
|
"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}
|
||||||
</th>
|
</div>
|
||||||
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::ETA)>
|
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::ETA)>
|
||||||
<div class="flex items-center">"ETA" {move || sort_arrow(SortColumn::ETA)}</div>
|
"ETA" {move || sort_arrow(SortColumn::ETA)}
|
||||||
</th>
|
</div>
|
||||||
<th class="w-32 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
<div class="w-32 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
||||||
<div class="flex items-center">"Date" {move || sort_arrow(SortColumn::AddedDate)}</div>
|
"Date" {move || sort_arrow(SortColumn::AddedDate)}
|
||||||
</th>
|
</div>
|
||||||
</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>
|
</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={
|
// Regular List
|
||||||
let handle_context_menu = handle_context_menu.clone();
|
<div class="flex-1 overflow-y-auto min-h-0">
|
||||||
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() /> }
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when=move || menu_visible.0.get() fallback=|| ()>
|
// --- MOBILE VIEW ---
|
||||||
<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)) />
|
<div class="md:hidden flex flex-col h-full bg-muted/10 relative overflow-hidden">
|
||||||
</Show>
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,60 +205,50 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
#[component]
|
#[component]
|
||||||
fn TorrentRow(
|
fn TorrentRow(
|
||||||
hash: String,
|
hash: String,
|
||||||
selected_hash: ReadSignal<Option<String>>,
|
|
||||||
set_selected_hash: WriteSignal<Option<String>>,
|
|
||||||
on_context_menu: impl Fn(web_sys::MouseEvent, String) + 'static + Clone + Send + Sync,
|
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
let h = hash.clone();
|
let h = hash.clone();
|
||||||
let torrent = Memo::new(move |_| store.torrents.with(|map| map.get(&h).cloned()));
|
let torrent = Memo::new(move |_| store.torrents.with(|map| map.get(&h).cloned()));
|
||||||
|
|
||||||
|
let stored_hash = StoredValue::new(hash.clone());
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
||||||
{
|
{
|
||||||
let on_context_menu = on_context_menu.clone();
|
|
||||||
let hash = hash.clone();
|
|
||||||
move || {
|
move || {
|
||||||
let t = torrent.get().unwrap();
|
let t = torrent.get().unwrap();
|
||||||
let t_hash = hash.clone();
|
|
||||||
let t_name = t.name.clone();
|
let t_name = t.name.clone();
|
||||||
let status_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 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 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();
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<tr
|
<div
|
||||||
class=move || {
|
class=move || {
|
||||||
let base = "hover border-b border-base-200 select-none";
|
let selected = store.selected_torrent.get();
|
||||||
if selected_hash_clone.get() == Some(t_hash_row.clone()) { format!("{} bg-primary/10", base) } else { base.to_string() }
|
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
|
||||||
}
|
if is_selected {
|
||||||
on:contextmenu={
|
"flex items-center text-sm bg-primary/10 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full"
|
||||||
let t_hash = t_hash.clone();
|
} else {
|
||||||
let on_context_menu = on_context_menu.clone();
|
"flex items-center text-sm hover:bg-muted/50 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full"
|
||||||
move |e: web_sys::MouseEvent| on_context_menu(e, t_hash.clone())
|
}
|
||||||
}
|
|
||||||
on:click={
|
|
||||||
let t_hash = t_hash.clone();
|
|
||||||
let set_selected_hash = set_selected_hash.clone();
|
|
||||||
move |_| set_selected_hash.set(Some(t_hash.clone()))
|
|
||||||
}
|
}
|
||||||
|
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||||
>
|
>
|
||||||
<td class="font-medium truncate max-w-xs" title=t_name.clone()>{t_name.clone()}</td>
|
<div class="flex-1 min-w-0 px-2 font-medium truncate" title=t_name.clone()>{t_name.clone()}</div>
|
||||||
<td class="opacity-80 font-mono text-[11px]">{format_bytes(t.size)}</td>
|
<div class="w-24 px-2 font-mono text-xs text-muted-foreground">{format_bytes(t.size)}</div>
|
||||||
<td>
|
<div class="w-48 px-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<progress class={format!("progress w-24 {}", progress_class)} value={t.percent_complete} max="100"></progress>
|
<div class="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
<span class="text-[10px] opacity-70">{format!("{:.1}%", t.percent_complete)}</span>
|
<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>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td class={format!("text-[11px] font-medium {}", status_class)}>{format!("{:?}", t.status)}</td>
|
<div class={format!("w-24 px-2 text-xs font-medium {}", status_color)}>{format!("{:?}", t.status)}</div>
|
||||||
<td class="text-right font-mono text-[11px] opacity-80 text-success">{format_speed(t.down_rate)}</td>
|
<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>
|
||||||
<td class="text-right font-mono text-[11px] opacity-80 text-primary">{format_speed(t.up_rate)}</td>
|
<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>
|
||||||
<td class="text-right font-mono text-[11px] opacity-80">{format_duration(t.eta)}</td>
|
<div class="w-24 px-2 text-right font-mono text-xs text-muted-foreground">{format_duration(t.eta)}</div>
|
||||||
<td class="text-right font-mono text-[11px] opacity-80 whitespace-nowrap">{format_date(t.added_date)}</td>
|
<div class="w-32 px-2 text-right font-mono text-xs text-muted-foreground">{format_date(t.added_date)}</div>
|
||||||
</tr>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,88 +259,63 @@ fn TorrentRow(
|
|||||||
#[component]
|
#[component]
|
||||||
fn TorrentCard(
|
fn TorrentCard(
|
||||||
hash: String,
|
hash: String,
|
||||||
selected_hash: ReadSignal<Option<String>>,
|
|
||||||
set_selected_hash: WriteSignal<Option<String>>,
|
|
||||||
set_menu_position: WriteSignal<(i32, i32)>,
|
|
||||||
set_menu_visible: WriteSignal<bool>,
|
|
||||||
on_context_menu: impl Fn(web_sys::MouseEvent, String) + 'static + Clone + Send + Sync,
|
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
let h = hash.clone();
|
let h = hash.clone();
|
||||||
let torrent = Memo::new(move |_| store.torrents.with(|map| map.get(&h).cloned()));
|
let torrent = Memo::new(move |_| store.torrents.with(|map| map.get(&h).cloned()));
|
||||||
|
|
||||||
|
let stored_hash = StoredValue::new(hash.clone());
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
||||||
{
|
{
|
||||||
let hash = hash.clone();
|
|
||||||
let on_context_menu = on_context_menu.clone();
|
|
||||||
move || {
|
move || {
|
||||||
let t = torrent.get().unwrap();
|
let t = torrent.get().unwrap();
|
||||||
let t_hash = hash.clone();
|
|
||||||
let t_name = t.name.clone();
|
let t_name = t.name.clone();
|
||||||
let status_badge_class = match t.status { shared::TorrentStatus::Seeding => "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 status_badge_class = match t.status { shared::TorrentStatus::Seeding => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border-green-200 dark:border-green-800", shared::TorrentStatus::Downloading => "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800", shared::TorrentStatus::Paused => "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800", shared::TorrentStatus::Error => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800", _ => "bg-muted text-muted-foreground" };
|
||||||
|
|
||||||
let t_hash_long = t_hash.clone();
|
|
||||||
let set_menu_position = set_menu_position.clone();
|
|
||||||
let set_selected_hash = set_selected_hash.clone();
|
|
||||||
let set_menu_visible = set_menu_visible.clone();
|
|
||||||
let 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();
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<div
|
||||||
class=move || {
|
class=move || {
|
||||||
let base = "card card-compact bg-base-100 shadow-sm border border-base-200 select-none cursor-pointer";
|
let selected = store.selected_torrent.get();
|
||||||
if selected_hash_clone.get() == Some(t_hash_card.clone()) { format!("{} ring-2 ring-primary ring-inset", base) } else { base.to_string() }
|
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
|
||||||
}
|
if is_selected {
|
||||||
on:contextmenu={
|
"ring-2 ring-primary rounded-lg transition-all"
|
||||||
let t_hash = t_hash.clone();
|
} else {
|
||||||
let on_context_menu = on_context_menu.clone();
|
"transition-all"
|
||||||
move |e: web_sys::MouseEvent| on_context_menu(e, t_hash.clone())
|
}
|
||||||
}
|
|
||||||
on:click={
|
|
||||||
let t_hash = t_hash.clone();
|
|
||||||
let set_selected_hash = set_selected_hash.clone();
|
|
||||||
move |_| set_selected_hash.set(Some(t_hash.clone()))
|
|
||||||
}
|
|
||||||
on:touchstart={
|
|
||||||
let start = start.clone();
|
|
||||||
move |e: web_sys::TouchEvent| if let Some(touch) = e.touches().get(0) { start((touch.client_x(), touch.client_y())); }
|
|
||||||
}
|
}
|
||||||
|
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||||
>
|
>
|
||||||
<div class="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">
|
<div class="flex justify-between items-start gap-2">
|
||||||
<h3 class="font-medium text-sm line-clamp-2 leading-tight">{t_name.clone()}</h3>
|
<CardTitle class="text-sm font-medium leading-tight line-clamp-2">{t_name.clone()}</CardTitle>
|
||||||
<div class={format!("badge badge-xs text-[10px] whitespace-nowrap {}", status_badge_class)}>{format!("{:?}", t.status)}</div>
|
<div class={format!("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 {}", status_badge_class)}>{format!("{:?}", t.status)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-3 pt-2 gap-3 flex flex-col">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div class="flex justify-between text-[10px] opacity-70">
|
<div class="flex justify-between text-[10px] text-muted-foreground">
|
||||||
<span>{format_bytes(t.size)}</span>
|
<span>{format_bytes(t.size)}</span>
|
||||||
<span>{format!("{:.1}%", t.percent_complete)}</span>
|
<span>{format!("{:.1}%", t.percent_complete)}</span>
|
||||||
</div>
|
</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>
|
||||||
<div class="grid grid-cols-4 gap-2 text-[10px] font-mono opacity-80 pt-1 border-t border-base-200/50">
|
<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-success"><span>"DL"</span><span>{format_speed(t.down_rate)}</span></div>
|
<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-primary"><span>"UP"</span><span>{format_speed(t.up_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"><span>"ETA"</span><span>{format_duration(t.eta)}</span></div>
|
||||||
<div class="flex flex-col text-right"><span>"DATE"</span><span>{format_date(t.added_date)}</span></div>
|
<div class="flex flex-col text-right"><span>"DATE"</span><span>{format_date(t.added_date)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ use leptos::prelude::*;
|
|||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use shared::{AppEvent, GlobalStats, NotificationLevel, SystemNotification, Torrent};
|
use shared::{AppEvent, GlobalStats, NotificationLevel, SystemNotification, Torrent};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use serde::{Serialize, Deserialize};
|
use struct_patch::traits::Patch;
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct NotificationItem {
|
pub struct NotificationItem {
|
||||||
@@ -55,18 +56,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,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum FilterStatus {
|
pub enum FilterStatus {
|
||||||
All, Downloading, Seeding, Completed, Paused, Inactive, Active, Error,
|
All, Downloading, Seeding, Completed, Paused, Inactive, Active, Error,
|
||||||
@@ -80,6 +69,7 @@ pub struct TorrentStore {
|
|||||||
pub global_stats: RwSignal<GlobalStats>,
|
pub global_stats: RwSignal<GlobalStats>,
|
||||||
pub notifications: RwSignal<Vec<NotificationItem>>,
|
pub notifications: RwSignal<Vec<NotificationItem>>,
|
||||||
pub user: RwSignal<Option<String>>,
|
pub user: RwSignal<Option<String>>,
|
||||||
|
pub selected_torrent: RwSignal<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn provide_torrent_store() {
|
pub fn provide_torrent_store() {
|
||||||
@@ -89,13 +79,13 @@ pub fn provide_torrent_store() {
|
|||||||
let global_stats = RwSignal::new(GlobalStats::default());
|
let global_stats = RwSignal::new(GlobalStats::default());
|
||||||
let notifications = RwSignal::new(Vec::<NotificationItem>::new());
|
let notifications = RwSignal::new(Vec::<NotificationItem>::new());
|
||||||
let user = RwSignal::new(Option::<String>::None);
|
let user = RwSignal::new(Option::<String>::None);
|
||||||
|
let selected_torrent = RwSignal::new(Option::<String>::None);
|
||||||
|
|
||||||
let show_browser_notification = crate::utils::notification::use_app_notification();
|
let show_browser_notification = crate::utils::notification::use_app_notification();
|
||||||
|
|
||||||
let store = TorrentStore { torrents, filter, search_query, global_stats, notifications, user };
|
let store = TorrentStore { torrents, filter, search_query, global_stats, notifications, user, selected_torrent };
|
||||||
provide_context(store);
|
provide_context(store);
|
||||||
|
|
||||||
let user_for_sse = user;
|
|
||||||
let notifications_for_sse = notifications;
|
let notifications_for_sse = notifications;
|
||||||
let global_stats_for_sse = global_stats;
|
let global_stats_for_sse = global_stats;
|
||||||
let torrents_for_sse = torrents;
|
let torrents_for_sse = torrents;
|
||||||
@@ -107,13 +97,6 @@ pub fn provide_torrent_store() {
|
|||||||
let mut disconnect_notified = false;
|
let mut disconnect_notified = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let user_val = user_for_sse.get();
|
|
||||||
log::debug!("SSE: user = {:?}", user_val);
|
|
||||||
if user_val.is_none() {
|
|
||||||
log::debug!("SSE: User not authenticated, waiting...");
|
|
||||||
gloo_timers::future::TimeoutFuture::new(1000).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::debug!("SSE: Creating EventSource...");
|
log::debug!("SSE: Creating EventSource...");
|
||||||
let es_result = EventSource::new("/api/events");
|
let es_result = EventSource::new("/api/events");
|
||||||
@@ -136,44 +119,47 @@ pub fn provide_torrent_store() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(data_str) = msg.data().as_string() {
|
if let Some(data_str) = msg.data().as_string() {
|
||||||
log::debug!("SSE: Parsing JSON: {}", data_str);
|
// Decode Base64
|
||||||
if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) {
|
match BASE64.decode(&data_str) {
|
||||||
match event {
|
Ok(bytes) => {
|
||||||
AppEvent::FullList { torrents: list, .. } => {
|
// Deserialize MessagePack
|
||||||
log::info!("SSE: Received FullList with {} torrents", list.len());
|
match rmp_serde::from_slice::<AppEvent>(&bytes) {
|
||||||
torrents_for_sse.update(|map| {
|
Ok(event) => {
|
||||||
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
|
match event {
|
||||||
map.retain(|hash, _| new_hashes.contains(hash));
|
AppEvent::FullList(list, _) => {
|
||||||
for new_torrent in list {
|
log::info!("SSE: Received FullList with {} torrents", list.len());
|
||||||
map.insert(new_torrent.hash.clone(), new_torrent);
|
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()));
|
||||||
|
}
|
||||||
|
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_with_signal(notifications_for_sse, n.level.clone(), n.message.clone());
|
||||||
|
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
|
||||||
|
show_browser_notification("VibeTorrent", &n.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
log::debug!("SSE: torrents map now has {} entries", torrents_for_sse.with(|m| m.len()));
|
|
||||||
}
|
|
||||||
AppEvent::Update(update) => {
|
|
||||||
torrents_for_sse.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::Stats(stats) => { global_stats_for_sse.set(stats); }
|
|
||||||
AppEvent::Notification(n) => {
|
|
||||||
show_toast_with_signal(notifications_for_sse, 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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
|
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} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ["./index.html", "./src/**/*.{rs,html}"],
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{rs,html}",
|
||||||
|
cargoRegistry,
|
||||||
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
@@ -3,6 +3,33 @@ name = "shared"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
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"
|
||||||
|
|
||||||
|
# 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 }
|
||||||
|
axum = { version = "0.8", features = ["macros"], 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 }
|
||||||
|
|
||||||
|
# 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]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
ssr = [
|
ssr = [
|
||||||
@@ -11,22 +38,13 @@ ssr = [
|
|||||||
"dep:thiserror",
|
"dep:thiserror",
|
||||||
"dep:quick-xml",
|
"dep:quick-xml",
|
||||||
"dep:leptos_axum",
|
"dep:leptos_axum",
|
||||||
|
"dep:sqlx",
|
||||||
|
"dep:anyhow",
|
||||||
|
"dep:jsonwebtoken",
|
||||||
|
"dep:cookie",
|
||||||
|
"dep:bcrypt",
|
||||||
|
"dep:axum",
|
||||||
"leptos/ssr",
|
"leptos/ssr",
|
||||||
"leptos_router/ssr",
|
"leptos_router/ssr",
|
||||||
]
|
]
|
||||||
hydrate = ["leptos/hydrate"]
|
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,4 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use struct_patch::Patch;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
@@ -7,6 +8,9 @@ pub mod scgi;
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod xmlrpc;
|
pub mod xmlrpc;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod db;
|
||||||
|
|
||||||
pub mod server_fns;
|
pub mod server_fns;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -14,7 +18,15 @@ pub struct ServerContext {
|
|||||||
pub scgi_socket_path: String,
|
pub scgi_socket_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
|
#[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 struct Torrent {
|
||||||
pub hash: String,
|
pub hash: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -41,12 +53,8 @@ pub enum TorrentStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||||
#[serde(tag = "type", content = "data")]
|
|
||||||
pub enum AppEvent {
|
pub enum AppEvent {
|
||||||
FullList {
|
FullList(Vec<Torrent>, u64),
|
||||||
torrents: Vec<Torrent>,
|
|
||||||
timestamp: u64,
|
|
||||||
},
|
|
||||||
Update(TorrentUpdate),
|
Update(TorrentUpdate),
|
||||||
Stats(GlobalStats),
|
Stats(GlobalStats),
|
||||||
Notification(SystemNotification),
|
Notification(SystemNotification),
|
||||||
@@ -75,20 +83,8 @@ pub struct GlobalStats {
|
|||||||
pub free_space: Option<i64>,
|
pub free_space: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
// REMOVED: Manual TorrentUpdate struct definition as it's now generated by Patch macro
|
||||||
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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||||
pub struct TorrentActionRequest {
|
pub struct TorrentActionRequest {
|
||||||
|
|||||||
168
shared/src/server_fns/auth.rs
Normal file
168
shared/src/server_fns/auth.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[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")]
|
||||||
|
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")]
|
||||||
|
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")]
|
||||||
|
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")]
|
||||||
|
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")]
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
pub mod torrent;
|
pub mod torrent;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod push;
|
||||||
|
pub mod auth;
|
||||||
22
shared/src/server_fns/push.rs
Normal file
22
shared/src/server_fns/push.rs
Normal 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)))
|
||||||
|
}
|
||||||
BIN
vibetorrent.db
BIN
vibetorrent.db
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user