Compare commits
30 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a24e4101e8 | ||
|
|
7539307e18 | ||
|
|
907ae66a7f | ||
|
|
f35b119c0d | ||
|
|
920704ee72 | ||
|
|
d8ad9e62d8 | ||
|
|
ea99ac62bc | ||
|
|
af13b5af09 | ||
|
|
c8907e7999 | ||
|
|
714e2cb7d5 | ||
|
|
f35b716f93 | ||
|
|
47db9fa0c0 | ||
|
|
47dc4da6d1 | ||
|
|
c501ed9207 | ||
|
|
4861faee18 | ||
|
|
6a4943d692 | ||
|
|
b27caa77f2 | ||
|
|
cba8c20d9b | ||
|
|
0cdd92dc95 | ||
|
|
b9798ce0e2 | ||
|
|
6a882b75b6 | ||
|
|
40c9f66e5c | ||
|
|
93e853977a | ||
|
|
e3bc956256 | ||
|
|
5b016aca58 | ||
|
|
5bd3d31dd6 | ||
|
|
87ddd3bb93 | ||
|
|
463249982c | ||
|
|
9447a66cc1 | ||
|
|
45247a020e |
417
Cargo.lock
generated
417
Cargo.lock
generated
@@ -1256,29 +1256,14 @@ dependencies = [
|
|||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"console_log",
|
"console_log",
|
||||||
"futures",
|
"futures",
|
||||||
|
"gloo-console",
|
||||||
"gloo-net",
|
"gloo-net",
|
||||||
"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-tabs",
|
|
||||||
"leptos-shadcn-toast",
|
|
||||||
"leptos-shadcn-tooltip",
|
|
||||||
"leptos-use",
|
"leptos-use",
|
||||||
"leptos_router",
|
"leptos_router",
|
||||||
|
"leptos_ui",
|
||||||
"log",
|
"log",
|
||||||
"rmp-serde",
|
"rmp-serde",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1286,8 +1271,10 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"shared",
|
"shared",
|
||||||
"struct-patch",
|
"struct-patch",
|
||||||
|
"strum",
|
||||||
"tailwind_fuse",
|
"tailwind_fuse",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"tw_merge",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
@@ -1439,6 +1426,19 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gloo-console"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261"
|
||||||
|
dependencies = [
|
||||||
|
"gloo-utils",
|
||||||
|
"js-sys",
|
||||||
|
"serde",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gloo-net"
|
name = "gloo-net"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -2155,319 +2155,6 @@ 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-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"
|
||||||
@@ -2673,6 +2360,17 @@ dependencies = [
|
|||||||
"tachys",
|
"tachys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "leptos_ui"
|
||||||
|
version = "0.3.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7c30ca85b1aac5637bc59a9201a6aeb648452679bf0ef0e451a8f30acf153f7"
|
||||||
|
dependencies = [
|
||||||
|
"leptos",
|
||||||
|
"paste",
|
||||||
|
"tw_merge",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.180"
|
version = "0.2.180"
|
||||||
@@ -3965,6 +3663,7 @@ dependencies = [
|
|||||||
"inventory",
|
"inventory",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rmp-serde",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
"send_wrapper",
|
"send_wrapper",
|
||||||
@@ -4050,6 +3749,7 @@ dependencies = [
|
|||||||
"bcrypt",
|
"bcrypt",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
|
"http 1.4.0",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"leptos",
|
"leptos",
|
||||||
"leptos_axum",
|
"leptos_axum",
|
||||||
@@ -4431,6 +4131,28 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.26.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum_macros"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn 2.0.114",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -4541,19 +4263,6 @@ 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]]
|
||||||
@@ -4978,6 +4687,28 @@ dependencies = [
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tw_merge"
|
||||||
|
version = "0.1.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25e4ae38c226104e3c821c60b311bca321f45dcf46e48b683a0db2fac9e2c6e2"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
"tw_merge_variants",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tw_merge_variants"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "03de956478d5562138828bb736cc066949bda33dbb99c55ef77b2bb5438868e4"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typed-builder"
|
name = "typed-builder"
|
||||||
version = "0.21.2"
|
version = "0.21.2"
|
||||||
|
|||||||
@@ -1,154 +1,2 @@
|
|||||||
use crate::AppState;
|
// This file is intentionally empty as authentication is now handled by Server Functions.
|
||||||
use axum::{
|
// See shared/src/server_fns/auth.rs
|
||||||
extract::{State, Json},
|
|
||||||
http::StatusCode,
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
|
||||||
use time::Duration;
|
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
|
||||||
pub struct LoginRequest {
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
#[serde(default)]
|
|
||||||
remember_me: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
|
||||||
pub struct UserResponse {
|
|
||||||
username: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/login",
|
|
||||||
request_body = LoginRequest,
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Login successful"),
|
|
||||||
(status = 401, description = "Invalid credentials"),
|
|
||||||
(status = 500, description = "Internal server error")
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn login_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
jar: CookieJar,
|
|
||||||
Json(payload): Json<LoginRequest>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
tracing::info!("Login attempt for user: {}", payload.username);
|
|
||||||
|
|
||||||
let user = match state.db.get_user_by_username(&payload.username).await {
|
|
||||||
Ok(Some(u)) => u,
|
|
||||||
Ok(None) => {
|
|
||||||
tracing::warn!("Login failed: User not found for {}", payload.username);
|
|
||||||
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("DB error during login for {}: {}", payload.username, e);
|
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let (user_id, password_hash) = user;
|
|
||||||
|
|
||||||
match bcrypt::verify(&payload.password, &password_hash) {
|
|
||||||
Ok(true) => {
|
|
||||||
tracing::info!("Password verified for user: {}", payload.username);
|
|
||||||
|
|
||||||
// Create session
|
|
||||||
let token: String = (0..32).map(|_| {
|
|
||||||
use rand::{distributions::Alphanumeric, Rng};
|
|
||||||
rand::thread_rng().sample(Alphanumeric) as char
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
// Expiration: 30 days if remember_me is true, else 1 day
|
|
||||||
let expires_in = if payload.remember_me {
|
|
||||||
60 * 60 * 24 * 30
|
|
||||||
} else {
|
|
||||||
60 * 60 * 24
|
|
||||||
};
|
|
||||||
|
|
||||||
let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + expires_in;
|
|
||||||
|
|
||||||
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
|
|
||||||
tracing::error!("Failed to create session for {}: {}", payload.username, e);
|
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create session").into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut cookie = Cookie::build(("auth_token", token))
|
|
||||||
.path("/")
|
|
||||||
.http_only(true)
|
|
||||||
.same_site(SameSite::Lax)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
cookie.set_max_age(Duration::seconds(expires_in));
|
|
||||||
|
|
||||||
tracing::info!("Session created and cookie set for user: {}", payload.username);
|
|
||||||
(StatusCode::OK, jar.add(cookie), Json(UserResponse { username: payload.username })).into_response()
|
|
||||||
}
|
|
||||||
Ok(false) => {
|
|
||||||
tracing::warn!("Login failed: Invalid password for {}", payload.username);
|
|
||||||
(StatusCode::UNAUTHORIZED, "Invalid credentials").into_response()
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Bcrypt error for {}: {}", payload.username, e);
|
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Auth error").into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/logout",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Logged out")
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn logout_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
jar: CookieJar,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
if let Some(token) = jar.get("auth_token") {
|
|
||||||
let _ = state.db.delete_session(token.value()).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cookie = Cookie::build(("auth_token", ""))
|
|
||||||
.path("/")
|
|
||||||
.http_only(true)
|
|
||||||
.max_age(Duration::seconds(-1)) // Expire immediately
|
|
||||||
.build();
|
|
||||||
|
|
||||||
(StatusCode::OK, jar.add(cookie), "Logged out").into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/auth/check",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Authenticated", body = UserResponse),
|
|
||||||
(status = 401, description = "Not authenticated")
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn check_auth_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
jar: CookieJar,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
if let Some(token) = jar.get("auth_token") {
|
|
||||||
match state.db.get_session_user(token.value()).await {
|
|
||||||
Ok(Some(user_id)) => {
|
|
||||||
// Fetch username
|
|
||||||
// We need a helper in db.rs to get username by id, or we can use a direct query here if we don't want to change db.rs interface yet.
|
|
||||||
// But better to add `get_username_by_id` to db.rs
|
|
||||||
// For now let's query directly or via a new db method.
|
|
||||||
if let Ok(Some(username)) = state.db.get_username_by_id(user_id).await {
|
|
||||||
return (StatusCode::OK, Json(UserResponse { username })).into_response();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => {} // Invalid session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusCode::UNAUTHORIZED.into_response()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,125 +1,2 @@
|
|||||||
use crate::AppState;
|
// This file is intentionally empty as setup is now handled by Server Functions.
|
||||||
use axum::{
|
// See shared/src/server_fns/auth.rs
|
||||||
extract::{State, Json},
|
|
||||||
http::StatusCode,
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
|
||||||
use time::Duration;
|
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
|
||||||
pub struct SetupRequest {
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
|
||||||
pub struct SetupStatusResponse {
|
|
||||||
completed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/setup/status",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Setup status", body = SetupStatusResponse)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn get_setup_status_handler(State(state): State<AppState>) -> impl IntoResponse {
|
|
||||||
let completed = match state.db.has_users().await {
|
|
||||||
Ok(has) => has,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("DB error checking users: {}", e);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Json(SetupStatusResponse { completed }).into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/setup",
|
|
||||||
request_body = SetupRequest,
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Setup completed and logged in"),
|
|
||||||
(status = 400, description = "Invalid request"),
|
|
||||||
(status = 403, description = "Setup already completed"),
|
|
||||||
(status = 500, description = "Internal server error")
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn setup_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
jar: CookieJar,
|
|
||||||
Json(payload): Json<SetupRequest>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
// 1. Check if setup is already completed (i.e., users exist)
|
|
||||||
match state.db.has_users().await {
|
|
||||||
Ok(true) => return (StatusCode::FORBIDDEN, "Setup already completed").into_response(),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("DB error checking users: {}", e);
|
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
|
|
||||||
}
|
|
||||||
Ok(false) => {} // Proceed
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Validate input
|
|
||||||
if payload.username.len() < 3 || payload.password.len() < 6 {
|
|
||||||
return (StatusCode::BAD_REQUEST, "Username must be at least 3 chars, password at least 6").into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Create User
|
|
||||||
// Lower cost for faster login on low-power devices (MIPS routers etc.)
|
|
||||||
let password_hash = match bcrypt::hash(&payload.password, 6) {
|
|
||||||
Ok(h) => h,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to hash password: {}", e);
|
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to process password").into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = state.db.create_user(&payload.username, &password_hash).await {
|
|
||||||
tracing::error!("Failed to create user: {}", e);
|
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create user").into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Auto-Login (Create Session)
|
|
||||||
// Get the created user's ID
|
|
||||||
let user = match state.db.get_user_by_username(&payload.username).await {
|
|
||||||
Ok(Some(u)) => u,
|
|
||||||
Ok(None) => return (StatusCode::INTERNAL_SERVER_ERROR, "User created but not found").into_response(),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("DB error fetching new user: {}", e);
|
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let (user_id, _) = user;
|
|
||||||
|
|
||||||
// Create session token
|
|
||||||
let token: String = (0..32).map(|_| {
|
|
||||||
use rand::{distributions::Alphanumeric, Rng};
|
|
||||||
rand::thread_rng().sample(Alphanumeric) as char
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
// Default expiration: 1 day (since it's not "remember me")
|
|
||||||
let expires_in = 60 * 60 * 24;
|
|
||||||
let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + expires_in;
|
|
||||||
|
|
||||||
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
|
|
||||||
tracing::error!("Failed to create session for new user: {}", e);
|
|
||||||
// Even if session fails, setup is technically complete, but login failed.
|
|
||||||
// We return OK but user will have to login manually.
|
|
||||||
return (StatusCode::OK, "Setup completed, please login").into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut cookie = Cookie::build(("auth_token", token))
|
|
||||||
.path("/")
|
|
||||||
.http_only(true)
|
|
||||||
.same_site(SameSite::Lax)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
cookie.set_max_age(Duration::seconds(expires_in));
|
|
||||||
|
|
||||||
(StatusCode::OK, jar.add(cookie), "Setup completed and logged in").into_response()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::{broadcast, watch};
|
use tokio::sync::{broadcast, watch};
|
||||||
use tower::ServiceBuilder;
|
use tower::ServiceBuilder;
|
||||||
use tower_governor::GovernorLayer;
|
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
compression::{CompressionLayer, CompressionLevel},
|
compression::{CompressionLayer, CompressionLevel},
|
||||||
cors::CorsLayer,
|
cors::CorsLayer,
|
||||||
@@ -48,7 +47,7 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn auth_middleware(
|
async fn auth_middleware(
|
||||||
state: axum::extract::State<AppState>,
|
_state: axum::extract::State<AppState>,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
request: Request<Body>,
|
request: Request<Body>,
|
||||||
next: Next,
|
next: Next,
|
||||||
@@ -113,13 +112,6 @@ struct Args {
|
|||||||
#[cfg(feature = "swagger")]
|
#[cfg(feature = "swagger")]
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
|
||||||
handlers::auth::login_handler,
|
|
||||||
handlers::auth::logout_handler,
|
|
||||||
handlers::auth::check_auth_handler,
|
|
||||||
handlers::setup::setup_handler,
|
|
||||||
handlers::setup::get_setup_status_handler
|
|
||||||
),
|
|
||||||
components(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
shared::AddTorrentRequest,
|
shared::AddTorrentRequest,
|
||||||
@@ -132,10 +124,6 @@ struct Args {
|
|||||||
shared::SetFilePriorityRequest,
|
shared::SetFilePriorityRequest,
|
||||||
shared::SetLabelRequest,
|
shared::SetLabelRequest,
|
||||||
shared::GlobalLimitRequest,
|
shared::GlobalLimitRequest,
|
||||||
handlers::auth::LoginRequest,
|
|
||||||
handlers::setup::SetupRequest,
|
|
||||||
handlers::setup::SetupStatusResponse,
|
|
||||||
handlers::auth::UserResponse
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
@@ -144,6 +132,7 @@ struct Args {
|
|||||||
)]
|
)]
|
||||||
struct ApiDoc;
|
struct ApiDoc;
|
||||||
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
// Load .env file
|
// Load .env file
|
||||||
@@ -423,6 +412,7 @@ async fn main() {
|
|||||||
#[cfg(feature = "swagger")]
|
#[cfg(feature = "swagger")]
|
||||||
let app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));
|
let app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));
|
||||||
|
|
||||||
|
// Setup & Auth Routes (cookie-based, stay as REST)
|
||||||
// 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 db_for_ctx = db.clone();
|
||||||
|
|||||||
@@ -1,16 +1,3 @@
|
|||||||
use governor::clock::QuantaInstant;
|
// This file can be removed or repurposed if rate limiting is needed for other endpoints.
|
||||||
use governor::middleware::NoOpMiddleware;
|
// Login rate limiting is now handled within the server function or needs to be reimplemented
|
||||||
use tower_governor::governor::GovernorConfig;
|
// as a middleware for the server function endpoint.
|
||||||
use tower_governor::governor::GovernorConfigBuilder;
|
|
||||||
use tower_governor::key_extractor::SmartIpKeyExtractor;
|
|
||||||
|
|
||||||
pub fn get_login_rate_limit_config() -> GovernorConfig<SmartIpKeyExtractor, NoOpMiddleware<QuantaInstant>> {
|
|
||||||
// 5 yanlış denemeden sonra bloklanır.
|
|
||||||
// Her yeni hak için 60 saniye (1 dakika) bekleme süresi.
|
|
||||||
GovernorConfigBuilder::default()
|
|
||||||
.key_extractor(SmartIpKeyExtractor)
|
|
||||||
.per_second(60)
|
|
||||||
.burst_size(5)
|
|
||||||
.finish()
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use shared::xmlrpc::{
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::response::sse::{Event, Sse};
|
use axum::response::sse::{Event, Sse};
|
||||||
use futures::stream::{self, Stream};
|
use futures::stream::{self};
|
||||||
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;
|
||||||
|
|||||||
1
frontend/.gitignore
vendored
Normal file
1
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
@@ -7,7 +7,7 @@ edition = "2021"
|
|||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
leptos = { version = "0.8.15", features = ["csr"] }
|
leptos = { version = "0.8.15", features = ["csr", "msgpack"] }
|
||||||
leptos_router = { version = "0.8.11" }
|
leptos_router = { version = "0.8.11" }
|
||||||
|
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
@@ -17,6 +17,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
gloo-net = "0.6"
|
gloo-net = "0.6"
|
||||||
gloo-timers = { version = "0.3", features = ["futures"] }
|
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||||
|
gloo-console = "0.3"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
uuid = { version = "1", features = ["v4", "js"] }
|
uuid = { version = "1", features = ["v4", "js"] }
|
||||||
@@ -34,21 +35,10 @@ thiserror = "2.0"
|
|||||||
rmp-serde = "1.3"
|
rmp-serde = "1.3"
|
||||||
struct-patch = "0.5"
|
struct-patch = "0.5"
|
||||||
|
|
||||||
# ShadCN UI Components (Individual)
|
# Rust/UI Components
|
||||||
leptos-shadcn-button = "0.8"
|
leptos_ui = "0.3"
|
||||||
leptos-shadcn-input = "0.8"
|
tw_merge = "0.1"
|
||||||
leptos-shadcn-card = "0.8"
|
strum = { version = "0.26", features = ["derive"] }
|
||||||
leptos-shadcn-badge = "0.8"
|
|
||||||
leptos-shadcn-context-menu = "0.8"
|
[package.metadata.leptos]
|
||||||
leptos-shadcn-separator = "0.8"
|
tailwind-input-file = "input.css"
|
||||||
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"
|
|
||||||
@@ -1,101 +1,102 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
|
||||||
/>
|
|
||||||
<title>VibeTorrent</title>
|
|
||||||
|
|
||||||
<!-- PWA & Mobile Capable -->
|
<head>
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta charset="utf-8" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="viewport"
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="apple-mobile-web-app-title" content="VibeTorrent" />
|
<title>VibeTorrent</title>
|
||||||
<meta name="theme-color" content="#111827" />
|
|
||||||
<link rel="manifest" href="manifest.json" />
|
|
||||||
<link rel="icon" type="image/png" href="icon-192.png" />
|
|
||||||
<link rel="apple-touch-icon" href="icon-192.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
|
|
||||||
|
|
||||||
<!-- Trunk Assets -->
|
<!-- PWA & Mobile Capable -->
|
||||||
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<link data-trunk rel="css" href="public/tailwind.css" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<link data-trunk rel="copy-file" href="manifest.json" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<link data-trunk rel="copy-file" href="icon-192.png" />
|
<meta name="apple-mobile-web-app-title" content="VibeTorrent" />
|
||||||
<link data-trunk rel="copy-file" href="icon-512.png" />
|
<meta name="theme-color" content="#111827" />
|
||||||
<link data-trunk rel="copy-file" href="sw.js" />
|
<link rel="manifest" href="manifest.json" />
|
||||||
<script>
|
<link rel="icon" type="image/png" href="icon-192.png" />
|
||||||
(function () {
|
<link rel="apple-touch-icon" href="icon-192.png" />
|
||||||
var localTheme = localStorage.getItem("vibetorrent_theme");
|
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
|
||||||
var t = localTheme || "dark";
|
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
|
||||||
if (t === "Amoled") t = "black";
|
|
||||||
if (t === "Light") t = "light";
|
|
||||||
if (t === "Dark" || t === "Midnight") t = "dark";
|
|
||||||
|
|
||||||
var theme = t.toLowerCase();
|
<!-- Trunk Assets -->
|
||||||
document.documentElement.setAttribute("data-theme", theme);
|
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" />
|
||||||
if (!localTheme) {
|
<link data-trunk rel="css" href="public/tailwind.css" />
|
||||||
localStorage.setItem("vibetorrent_theme", "dark");
|
<link data-trunk rel="copy-file" href="manifest.json" />
|
||||||
}
|
<link data-trunk rel="copy-file" href="icon-192.png" />
|
||||||
|
<link data-trunk rel="copy-file" href="icon-512.png" />
|
||||||
|
<link data-trunk rel="copy-file" href="sw.js" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var localTheme = localStorage.getItem("vibetorrent_theme");
|
||||||
|
var t = localTheme || "dark";
|
||||||
|
if (t === "Amoled") t = "black";
|
||||||
|
if (t === "Light") t = "light";
|
||||||
|
if (t === "Dark" || t === "Midnight") t = "dark";
|
||||||
|
|
||||||
var meta = document.querySelector('meta[name="theme-color"]');
|
var theme = t.toLowerCase();
|
||||||
if (meta) {
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
var colorMap = {
|
// Shadcn dark mode CSS değişkenleri .dark class ile çalışıyor
|
||||||
light: "#ffffff",
|
var darkThemes = ["dark", "black", "night", "coffee", "luxury", "business", "dracula", "halloween", "forest", "synthwave", "dim", "nord", "sunset", "cyberpunk", "abyss"];
|
||||||
cupcake: "#faf7f5",
|
if (darkThemes.indexOf(theme) !== -1) {
|
||||||
bumblebee: "#ffffff",
|
document.documentElement.classList.add("dark");
|
||||||
emerald: "#ffffff",
|
} else {
|
||||||
corporate: "#ffffff",
|
document.documentElement.classList.remove("dark");
|
||||||
synthwave: "#2d1b69",
|
}
|
||||||
retro: "#ece3ca",
|
if (!localTheme) {
|
||||||
cyberpunk: "#ffee00",
|
localStorage.setItem("vibetorrent_theme", "dark");
|
||||||
valentine: "#f0d6e8",
|
}
|
||||||
halloween: "#212121",
|
|
||||||
garden: "#e9e7e7",
|
|
||||||
forest: "#171212",
|
|
||||||
aqua: "#345da7",
|
|
||||||
lofi: "#ffffff",
|
|
||||||
pastel: "#ffffff",
|
|
||||||
fantasy: "#ffffff",
|
|
||||||
wireframe: "#ffffff",
|
|
||||||
black: "#000000",
|
|
||||||
luxury: "#09090b",
|
|
||||||
dracula: "#282a36",
|
|
||||||
cmyk: "#ffffff",
|
|
||||||
autumn: "#8C0327",
|
|
||||||
business: "#202020",
|
|
||||||
acid: "#fafafa",
|
|
||||||
lemonade: "#F1F8E8",
|
|
||||||
night: "#0f1729",
|
|
||||||
coffee: "#20161f",
|
|
||||||
winter: "#ffffff",
|
|
||||||
dark: "#1d232a",
|
|
||||||
};
|
|
||||||
var color = colorMap[theme] || "#1d232a";
|
|
||||||
meta.setAttribute("content", color);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body style="cursor: pointer;">
|
var meta = document.querySelector('meta[name="theme-color"]');
|
||||||
<div
|
if (meta) {
|
||||||
id="app-loading"
|
var colorMap = {
|
||||||
style="
|
light: "#ffffff",
|
||||||
|
cupcake: "#faf7f5",
|
||||||
|
bumblebee: "#ffffff",
|
||||||
|
emerald: "#ffffff",
|
||||||
|
corporate: "#ffffff",
|
||||||
|
synthwave: "#2d1b69",
|
||||||
|
retro: "#ece3ca",
|
||||||
|
cyberpunk: "#ffee00",
|
||||||
|
valentine: "#f0d6e8",
|
||||||
|
halloween: "#212121",
|
||||||
|
garden: "#e9e7e7",
|
||||||
|
forest: "#171212",
|
||||||
|
aqua: "#345da7",
|
||||||
|
lofi: "#ffffff",
|
||||||
|
pastel: "#ffffff",
|
||||||
|
fantasy: "#ffffff",
|
||||||
|
wireframe: "#ffffff",
|
||||||
|
black: "#000000",
|
||||||
|
luxury: "#09090b",
|
||||||
|
dracula: "#282a36",
|
||||||
|
cmyk: "#ffffff",
|
||||||
|
autumn: "#8C0327",
|
||||||
|
business: "#202020",
|
||||||
|
acid: "#fafafa",
|
||||||
|
lemonade: "#F1F8E8",
|
||||||
|
night: "#0f1729",
|
||||||
|
coffee: "#20161f",
|
||||||
|
winter: "#ffffff",
|
||||||
|
dark: "#1d232a",
|
||||||
|
};
|
||||||
|
var color = colorMap[theme] || "#1d232a";
|
||||||
|
meta.setAttribute("content", color);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="cursor: pointer;">
|
||||||
|
<div id="app-loading" style="
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
"
|
">
|
||||||
>
|
<div id="app-loading-spinner" style="
|
||||||
<div
|
|
||||||
id="app-loading-spinner"
|
|
||||||
style="
|
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: 3px solid currentColor;
|
border: 3px solid currentColor;
|
||||||
@@ -103,21 +104,15 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
"
|
"></div>
|
||||||
></div>
|
<div id="app-loading-error" style="display: none; text-align: center; margin-top: 20px; padding: 0 20px">
|
||||||
<div
|
<p style="color: #ef4444; font-weight: bold; margin-bottom: 8px">
|
||||||
id="app-loading-error"
|
Uygulama yüklenemedi
|
||||||
style="display: none; text-align: center; margin-top: 20px; padding: 0 20px"
|
</p>
|
||||||
>
|
<p style="font-size: 14px; opacity: 0.7">
|
||||||
<p style="color: #ef4444; font-weight: bold; margin-bottom: 8px">
|
Bağlantınız yavaş olabilir veya bir sistem hatası oluşmuş olabilir.
|
||||||
Uygulama yüklenemedi
|
</p>
|
||||||
</p>
|
<button onclick="location.reload()" style="
|
||||||
<p style="font-size: 14px; opacity: 0.7">
|
|
||||||
Bağlantınız yavaş olabilir veya bir sistem hatası oluşmuş olabilir.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onclick="location.reload()"
|
|
||||||
style="
|
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: #3b82f6;
|
background: #3b82f6;
|
||||||
@@ -126,104 +121,105 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
"
|
">
|
||||||
>
|
Sayfayı Yenile
|
||||||
Sayfayı Yenile
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<style>
|
</div>
|
||||||
@keyframes spin {
|
<style>
|
||||||
to {
|
@keyframes spin {
|
||||||
transform: rotate(360deg);
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.app-loaded #app-loading {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iOS Safari Click Fixes */
|
||||||
|
body {
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// App loading timeout handler
|
||||||
|
(function () {
|
||||||
|
var timeout = setTimeout(function () {
|
||||||
|
if (!document.body.classList.contains("app-loaded")) {
|
||||||
|
var spinner = document.getElementById("app-loading-spinner");
|
||||||
|
var error = document.getElementById("app-loading-error");
|
||||||
|
if (spinner) spinner.style.display = "none";
|
||||||
|
if (error) error.style.display = "block";
|
||||||
}
|
}
|
||||||
}
|
}, 15000); // 15 seconds timeout
|
||||||
|
|
||||||
body.app-loaded #app-loading {
|
// Clean up timeout if app loads
|
||||||
display: none !important;
|
var observer = new MutationObserver(function (mutations) {
|
||||||
}
|
mutations.forEach(function (mutation) {
|
||||||
|
if (
|
||||||
/* iOS Safari Click Fixes */
|
mutation.attributeName === "class" &&
|
||||||
body {
|
document.body.classList.contains("app-loaded")
|
||||||
cursor: pointer;
|
) {
|
||||||
-webkit-tap-highlight-color: transparent;
|
clearTimeout(timeout);
|
||||||
}
|
observer.disconnect();
|
||||||
|
|
||||||
summary {
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// App loading timeout handler
|
|
||||||
(function () {
|
|
||||||
var timeout = setTimeout(function () {
|
|
||||||
if (!document.body.classList.contains("app-loaded")) {
|
|
||||||
var spinner = document.getElementById("app-loading-spinner");
|
|
||||||
var error = document.getElementById("app-loading-error");
|
|
||||||
if (spinner) spinner.style.display = "none";
|
|
||||||
if (error) error.style.display = "block";
|
|
||||||
}
|
|
||||||
}, 15000); // 15 seconds timeout
|
|
||||||
|
|
||||||
// Clean up timeout if app loads
|
|
||||||
var observer = new MutationObserver(function (mutations) {
|
|
||||||
mutations.forEach(function (mutation) {
|
|
||||||
if (
|
|
||||||
mutation.attributeName === "class" &&
|
|
||||||
document.body.classList.contains("app-loaded")
|
|
||||||
) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
observer.disconnect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
observer.observe(document.body, { attributes: true });
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Service Worker Registration & PWA Setup -->
|
|
||||||
<script>
|
|
||||||
// Global Dropdown Closer for iOS/Mobile
|
|
||||||
document.addEventListener('click', function(event) {
|
|
||||||
const details = document.querySelectorAll('details[open]');
|
|
||||||
details.forEach(detail => {
|
|
||||||
// Eğer tıklanan yer bu details'in içinde değilse kapat
|
|
||||||
if (!detail.contains(event.target)) {
|
|
||||||
detail.removeAttribute('open');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, true); // Use capture phase for better mobile support
|
});
|
||||||
|
observer.observe(document.body, { attributes: true });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
if ("serviceWorker" in navigator) {
|
<!-- Service Worker Registration & PWA Setup -->
|
||||||
window.addEventListener("load", () => {
|
<script>
|
||||||
navigator.serviceWorker
|
// Global Dropdown Closer for iOS/Mobile
|
||||||
.register("/sw.js")
|
document.addEventListener('click', function (event) {
|
||||||
.then((registration) => {
|
const details = document.querySelectorAll('details[open]');
|
||||||
console.log("✅ Service Worker registered:", registration);
|
details.forEach(detail => {
|
||||||
|
// Eğer tıklanan yer bu details'in içinde değilse kapat
|
||||||
// Request notification permission after a delay (better UX)
|
if (!detail.contains(event.target)) {
|
||||||
setTimeout(() => {
|
detail.removeAttribute('open');
|
||||||
if ("Notification" in window && Notification.permission === "default") {
|
}
|
||||||
// Only request if user hasn't decided yet
|
});
|
||||||
const shouldRequest = localStorage.getItem("vibetorrent_notification_prompt_shown");
|
}, true); // Use capture phase for better mobile support
|
||||||
if (!shouldRequest) {
|
|
||||||
Notification.requestPermission().then((permission) => {
|
if ("serviceWorker" in navigator) {
|
||||||
console.log("Notification permission:", permission);
|
window.addEventListener("load", () => {
|
||||||
localStorage.setItem("vibetorrent_notification_prompt_shown", "true");
|
navigator.serviceWorker
|
||||||
});
|
.register("/sw.js")
|
||||||
}
|
.then((registration) => {
|
||||||
|
console.log("✅ Service Worker registered:", registration);
|
||||||
|
|
||||||
|
// Request notification permission after a delay (better UX)
|
||||||
|
setTimeout(() => {
|
||||||
|
if ("Notification" in window && Notification.permission === "default") {
|
||||||
|
// Only request if user hasn't decided yet
|
||||||
|
const shouldRequest = localStorage.getItem("vibetorrent_notification_prompt_shown");
|
||||||
|
if (!shouldRequest) {
|
||||||
|
Notification.requestPermission().then((permission) => {
|
||||||
|
console.log("Notification permission:", permission);
|
||||||
|
localStorage.setItem("vibetorrent_notification_prompt_shown", "true");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, 3000); // Wait 3 seconds before asking
|
}
|
||||||
})
|
}, 3000); // Wait 3 seconds before asking
|
||||||
.catch((error) => {
|
})
|
||||||
console.warn("⚠️ Service Worker registration failed:", error);
|
.catch((error) => {
|
||||||
});
|
console.warn("⚠️ Service Worker registration failed:", error);
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
</script>
|
}
|
||||||
</body>
|
</script>
|
||||||
</html>
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
2640
frontend/package-lock.json
generated
2640
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"author": "",
|
||||||
"version": "1.0.0",
|
"dependencies": {
|
||||||
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
"description": "",
|
"description": "",
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"postcss-cli": "^11.0.1",
|
||||||
|
"postcss-preset-env": "^10.1.3",
|
||||||
|
"tailwindcss": "^4.1.18"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"license": "ISC",
|
||||||
"main": "tailwind.config.js",
|
"main": "tailwind.config.js",
|
||||||
|
"name": "frontend",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"type": "module",
|
||||||
"author": "",
|
"version": "1.0.0"
|
||||||
"license": "ISC",
|
}
|
||||||
"devDependencies": {
|
|
||||||
"autoprefixer": "^10.4.23",
|
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"tailwindcss": "^4.1.18"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@tailwindcss/cli": "^4.1.18"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
frontend/postcss.config.cjs
Normal file
15
frontend/postcss.config.cjs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
"postcss-preset-env": {
|
||||||
|
features: {
|
||||||
|
"nesting-rules": true,
|
||||||
|
},
|
||||||
|
browsers: [
|
||||||
|
"last 2 versions",
|
||||||
|
"iOS >= 15",
|
||||||
|
"Safari >= 15",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,24 @@
|
|||||||
use crate::components::layout::protected::Protected;
|
use crate::components::layout::protected::Protected;
|
||||||
use crate::components::toast::ToastContainer;
|
|
||||||
use crate::components::torrent::table::TorrentTable;
|
use crate::components::torrent::table::TorrentTable;
|
||||||
use crate::components::torrent::detail::TorrentDetail;
|
|
||||||
use crate::components::auth::login::Login;
|
use crate::components::auth::login::Login;
|
||||||
use crate::components::auth::setup::Setup;
|
use crate::components::auth::setup::Setup;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
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 crate::components::ui::toast::Toaster;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
|
crate::components::ui::toast::provide_toaster();
|
||||||
|
view! {
|
||||||
|
<Toaster />
|
||||||
|
<InnerApp />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn InnerApp() -> impl IntoView {
|
||||||
crate::store::provide_torrent_store();
|
crate::store::provide_torrent_store();
|
||||||
let store = use_context::<crate::store::TorrentStore>();
|
let store = use_context::<crate::store::TorrentStore>();
|
||||||
|
|
||||||
@@ -21,6 +29,7 @@ pub fn App() -> impl IntoView {
|
|||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
log::info!("App initialization started...");
|
log::info!("App initialization started...");
|
||||||
|
gloo_console::log!("APP INIT: Checking setup status...");
|
||||||
|
|
||||||
// Check if setup is needed via Server Function
|
// Check if setup is needed via Server Function
|
||||||
match shared::server_fns::auth::get_setup_status().await {
|
match shared::server_fns::auth::get_setup_status().await {
|
||||||
@@ -53,6 +62,7 @@ pub fn App() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
is_loading.1.set(false);
|
is_loading.1.set(false);
|
||||||
|
crate::store::toast_success("VibeTorrent'e Hoşgeldiniz");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,10 +126,37 @@ pub fn App() -> impl IntoView {
|
|||||||
|
|
||||||
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-background">
|
<div class="flex h-screen bg-background">
|
||||||
<div class="flex flex-col items-center gap-4">
|
// Sidebar skeleton
|
||||||
<div class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
<div class="w-56 border-r border-border p-4 space-y-4">
|
||||||
<p class="text-sm text-muted-foreground">"Yükleniyor..."</p>
|
<div class="h-8 w-3/4 animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="h-6 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-6 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-6 w-4/5 animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-6 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-6 w-3/5 animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-6 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
// Main content skeleton
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
<div class="border-b border-border p-4 flex items-center gap-4">
|
||||||
|
<div class="h-8 w-48 animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-8 w-64 animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="ml-auto"><div class="h-8 w-24 animate-pulse rounded-md bg-muted" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 p-4 space-y-3">
|
||||||
|
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-10 w-3/4 animate-pulse rounded-md bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-border p-3">
|
||||||
|
<div class="h-5 w-96 animate-pulse rounded-md bg-muted" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}.into_any()>
|
}.into_any()>
|
||||||
@@ -129,7 +166,6 @@ pub fn App() -> impl IntoView {
|
|||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden">
|
||||||
<TorrentTable />
|
<TorrentTable />
|
||||||
</div>
|
</div>
|
||||||
<TorrentDetail />
|
|
||||||
</div>
|
</div>
|
||||||
</Protected>
|
</Protected>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -157,8 +193,6 @@ pub fn App() -> impl IntoView {
|
|||||||
}/>
|
}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
|
||||||
<ToastContainer />
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use leptos_shadcn_card::{Card, CardHeader, CardContent};
|
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
||||||
use leptos_shadcn_input::Input;
|
use crate::components::ui::input::{Input, InputType};
|
||||||
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 = RwSignal::new(String::new());
|
||||||
let password = signal(String::new());
|
let password = RwSignal::new(String::new());
|
||||||
let error = signal(Option::<String>::None);
|
let error = signal(Option::<String>::None);
|
||||||
let loading = signal(false);
|
let loading = signal(false);
|
||||||
|
|
||||||
@@ -18,8 +15,8 @@ pub fn Login() -> impl IntoView {
|
|||||||
loading.1.set(true);
|
loading.1.set(true);
|
||||||
error.1.set(None);
|
error.1.set(None);
|
||||||
|
|
||||||
let user = username.0.get();
|
let user = username.get();
|
||||||
let pass = password.0.get();
|
let pass = password.get();
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match shared::server_fns::auth::login(user, pass).await {
|
match shared::server_fns::auth::login(user, pass).await {
|
||||||
@@ -52,44 +49,40 @@ pub fn Login() -> impl IntoView {
|
|||||||
<CardContent class="pt-4">
|
<CardContent class="pt-4">
|
||||||
<form on:submit=handle_login class="space-y-4">
|
<form on:submit=handle_login class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>"Kullanıcı Adı"</Label>
|
<label class="text-sm font-medium leading-none">"Kullanıcı Adı"</label>
|
||||||
<Input
|
<Input
|
||||||
input_type="text"
|
r#type=InputType::Text
|
||||||
placeholder="Kullanıcı adınız"
|
placeholder="Kullanıcı adınız"
|
||||||
value=MaybeProp::derive(move || Some(username.0.get()))
|
bind_value=username
|
||||||
on_change=Callback::new(move |val: String| username.1.set(val))
|
disabled=loading.0.get()
|
||||||
disabled=Signal::derive(move || loading.0.get())
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>"Şifre"</Label>
|
<label class="text-sm font-medium leading-none">"Şifre"</label>
|
||||||
<Input
|
<Input
|
||||||
input_type="password"
|
r#type=InputType::Password
|
||||||
placeholder="******"
|
placeholder="******"
|
||||||
value=MaybeProp::derive(move || Some(password.0.get()))
|
bind_value=password
|
||||||
on_change=Callback::new(move |val: String| password.1.set(val))
|
disabled=loading.0.get()
|
||||||
disabled=Signal::derive(move || loading.0.get())
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when=move || error.0.get().is_some()>
|
<Show when=move || error.0.get().is_some()>
|
||||||
<Alert variant=AlertVariant::Destructive>
|
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
<AlertDescription>
|
{move || error.0.get().unwrap_or_default()}
|
||||||
{move || error.0.get().unwrap_or_default()}
|
</div>
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="pt-2">
|
<div class="pt-2">
|
||||||
<Button
|
<button
|
||||||
class="w-full"
|
class="inline-flex items-center justify-center w-full h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
|
||||||
disabled=Signal::derive(move || loading.0.get())
|
disabled=move || loading.0.get()
|
||||||
>
|
>
|
||||||
<Show when=move || loading.0.get() fallback=|| "Giriş Yap">
|
<Show when=move || loading.0.get() fallback=|| "Giriş Yap">
|
||||||
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
||||||
"Giriş Yapılıyor..."
|
"Giriş Yapılıyor..."
|
||||||
</Show>
|
</Show>
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use leptos_shadcn_card::{Card, CardHeader, CardContent};
|
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
||||||
use leptos_shadcn_input::Input;
|
use crate::components::ui::input::{Input, InputType};
|
||||||
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 {
|
||||||
let username = signal(String::new());
|
let username = RwSignal::new(String::new());
|
||||||
let password = signal(String::new());
|
let password = RwSignal::new(String::new());
|
||||||
let confirm_password = signal(String::new());
|
let confirm_password = RwSignal::new(String::new());
|
||||||
let error = signal(Option::<String>::None);
|
let error = signal(Option::<String>::None);
|
||||||
let loading = signal(false);
|
let loading = signal(false);
|
||||||
|
|
||||||
let handle_setup = move |ev: web_sys::SubmitEvent| {
|
let handle_setup = move |ev: web_sys::SubmitEvent| {
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
|
|
||||||
let pass = password.0.get();
|
let pass = password.get();
|
||||||
let confirm = confirm_password.0.get();
|
let confirm = confirm_password.get();
|
||||||
|
|
||||||
if pass != confirm {
|
if pass != confirm {
|
||||||
error.1.set(Some("Şifreler eşleşmiyor".to_string()));
|
error.1.set(Some("Şifreler eşleşmiyor".to_string()));
|
||||||
@@ -33,7 +30,7 @@ pub fn Setup() -> impl IntoView {
|
|||||||
loading.1.set(true);
|
loading.1.set(true);
|
||||||
error.1.set(None);
|
error.1.set(None);
|
||||||
|
|
||||||
let user = username.0.get();
|
let user = username.get();
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match shared::server_fns::auth::setup(user, pass).await {
|
match shared::server_fns::auth::setup(user, pass).await {
|
||||||
@@ -67,54 +64,49 @@ pub fn Setup() -> impl IntoView {
|
|||||||
<CardContent class="pt-4">
|
<CardContent class="pt-4">
|
||||||
<form on:submit=handle_setup class="space-y-4">
|
<form on:submit=handle_setup class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>"Yönetici Kullanıcı Adı"</Label>
|
<label class="text-sm font-medium leading-none">"Yönetici Kullanıcı Adı"</label>
|
||||||
<Input
|
<Input
|
||||||
input_type="text"
|
r#type=InputType::Text
|
||||||
placeholder="admin"
|
placeholder="admin"
|
||||||
value=MaybeProp::derive(move || Some(username.0.get()))
|
bind_value=username
|
||||||
on_change=Callback::new(move |val: String| username.1.set(val))
|
disabled=loading.0.get()
|
||||||
disabled=Signal::derive(move || loading.0.get())
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>"Şifre"</Label>
|
<label class="text-sm font-medium leading-none">"Şifre"</label>
|
||||||
<Input
|
<Input
|
||||||
input_type="password"
|
r#type=InputType::Password
|
||||||
placeholder="******"
|
placeholder="******"
|
||||||
value=MaybeProp::derive(move || Some(password.0.get()))
|
bind_value=password
|
||||||
on_change=Callback::new(move |val: String| password.1.set(val))
|
disabled=loading.0.get()
|
||||||
disabled=Signal::derive(move || loading.0.get())
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>"Şifre Onay"</Label>
|
<label class="text-sm font-medium leading-none">"Şifre Onay"</label>
|
||||||
<Input
|
<Input
|
||||||
input_type="password"
|
r#type=InputType::Password
|
||||||
placeholder="******"
|
placeholder="******"
|
||||||
value=MaybeProp::derive(move || Some(confirm_password.0.get()))
|
bind_value=confirm_password
|
||||||
on_change=Callback::new(move |val: String| confirm_password.1.set(val))
|
disabled=loading.0.get()
|
||||||
disabled=Signal::derive(move || loading.0.get())
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when=move || error.0.get().is_some() fallback=|| ()>
|
<Show when=move || error.0.get().is_some() fallback=|| ()>
|
||||||
<Alert variant=AlertVariant::Destructive>
|
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
<AlertDescription>
|
<span>{move || error.0.get().unwrap_or_default()}</span>
|
||||||
<span>{move || error.0.get().unwrap_or_default()}</span>
|
</div>
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="pt-2">
|
<div class="pt-2">
|
||||||
<Button
|
<button
|
||||||
class="w-full"
|
class="inline-flex items-center justify-center w-full h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
|
||||||
disabled=Signal::derive(move || loading.0.get())
|
disabled=move || loading.0.get()
|
||||||
>
|
>
|
||||||
<Show when=move || loading.0.get() fallback=|| "Kurulumu Tamamla">
|
<Show when=move || loading.0.get() fallback=|| "Kurulumu Tamamla">
|
||||||
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
||||||
"Kuruluyor..."
|
"Kuruluyor..."
|
||||||
</Show>
|
</Show>
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use leptos_shadcn_button::{Button, ButtonVariant, ButtonSize};
|
|
||||||
use leptos_shadcn_avatar::{Avatar, AvatarFallback};
|
use leptos_use::storage::use_local_storage;
|
||||||
use leptos_shadcn_separator::Separator;
|
use ::codee::string::FromToStringCodec;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Sidebar() -> impl IntoView {
|
pub fn Sidebar() -> impl IntoView {
|
||||||
@@ -67,6 +67,34 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
username().chars().next().unwrap_or('?').to_uppercase().to_string()
|
username().chars().next().unwrap_or('?').to_uppercase().to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- THEME LOGIC START ---
|
||||||
|
let (current_theme, set_current_theme, _) = use_local_storage::<String, FromToStringCodec>("vibetorrent_theme");
|
||||||
|
|
||||||
|
// Initialize with default if empty
|
||||||
|
let current_theme_val = current_theme.get();
|
||||||
|
if current_theme_val.is_empty() {
|
||||||
|
set_current_theme.set("dark".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatically sync theme to document attribute
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let theme = current_theme.get().to_lowercase();
|
||||||
|
if let Some(doc) = document().document_element() {
|
||||||
|
let _ = doc.set_attribute("data-theme", &theme);
|
||||||
|
if theme == "dark" || theme == "dracula" || theme == "dim" || theme == "abyss" || theme == "sunset" || theme == "cyberpunk" || theme == "nord" || theme == "business" || theme == "night" || theme == "black" || theme == "luxury" || theme == "coffee" || theme == "forest" || theme == "halloween" || theme == "synthwave" {
|
||||||
|
let _ = doc.class_list().add_1("dark");
|
||||||
|
} else {
|
||||||
|
let _ = doc.class_list().remove_1("dark");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let toggle_theme = move |_| {
|
||||||
|
let new_theme = if current_theme.get() == "dark" { "light" } else { "dark" };
|
||||||
|
set_current_theme.set(new_theme.to_string());
|
||||||
|
};
|
||||||
|
// --- THEME LOGIC END ---
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="w-full h-full flex flex-col bg-card" style="padding-top: env(safe-area-inset-top);">
|
<div class="w-full h-full flex flex-col bg-card" style="padding-top: env(safe-area-inset-top);">
|
||||||
<div class="p-4 flex-1 overflow-y-auto">
|
<div class="p-4 flex-1 overflow-y-auto">
|
||||||
@@ -76,118 +104,124 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<h4 class="mb-1 rounded-md px-2 py-1 text-sm font-semibold text-muted-foreground">"Filters"</h4>
|
<h4 class="mb-1 rounded-md px-2 py-1 text-sm font-semibold text-muted-foreground">"Filters"</h4>
|
||||||
|
|
||||||
<Button
|
<SidebarButton
|
||||||
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::All) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::All))
|
||||||
size=ButtonSize::Sm
|
on_click=move |_| set_filter(crate::store::FilterStatus::All)
|
||||||
class="w-full justify-start gap-2"
|
icon="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||||
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::All))
|
label="All"
|
||||||
>
|
count=Signal::derive(total_count)
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
/>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
<SidebarButton
|
||||||
</svg>
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Downloading))
|
||||||
"All"
|
on_click=move |_| set_filter(crate::store::FilterStatus::Downloading)
|
||||||
<span class="ml-auto text-xs font-mono opacity-70">{total_count}</span>
|
icon="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>
|
label="Downloading"
|
||||||
|
count=Signal::derive(downloading_count)
|
||||||
<Button
|
/>
|
||||||
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Downloading) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
<SidebarButton
|
||||||
size=ButtonSize::Sm
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Seeding))
|
||||||
class="w-full justify-start gap-2"
|
on_click=move |_| set_filter(crate::store::FilterStatus::Seeding)
|
||||||
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Downloading))
|
icon="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"
|
||||||
>
|
label="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">
|
count=Signal::derive(seeding_count)
|
||||||
<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>
|
<SidebarButton
|
||||||
"Downloading"
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Completed))
|
||||||
<span class="ml-auto text-xs font-mono opacity-70">{downloading_count}</span>
|
on_click=move |_| set_filter(crate::store::FilterStatus::Completed)
|
||||||
</Button>
|
icon="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
label="Completed"
|
||||||
<Button
|
count=Signal::derive(completed_count)
|
||||||
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Seeding) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
/>
|
||||||
size=ButtonSize::Sm
|
<SidebarButton
|
||||||
class="w-full justify-start gap-2"
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Paused))
|
||||||
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Seeding))
|
on_click=move |_| set_filter(crate::store::FilterStatus::Paused)
|
||||||
>
|
icon="M15.75 5.25v13.5m-7.5-13.5v13.5"
|
||||||
<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">
|
label="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" />
|
count=Signal::derive(paused_count)
|
||||||
</svg>
|
/>
|
||||||
"Seeding"
|
<SidebarButton
|
||||||
<span class="ml-auto text-xs font-mono opacity-70">{seeding_count}</span>
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Inactive))
|
||||||
</Button>
|
on_click=move |_| set_filter(crate::store::FilterStatus::Inactive)
|
||||||
|
icon="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"
|
||||||
<Button
|
label="Inactive"
|
||||||
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Completed) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
count=Signal::derive(inactive_count)
|
||||||
size=ButtonSize::Sm
|
/>
|
||||||
class="w-full justify-start gap-2"
|
|
||||||
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Completed))
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
"Completed"
|
|
||||||
<span class="ml-auto text-xs font-mono opacity-70">{completed_count}</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Paused) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
|
||||||
size=ButtonSize::Sm
|
|
||||||
class="w-full justify-start gap-2"
|
|
||||||
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Paused))
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
|
||||||
</svg>
|
|
||||||
"Paused"
|
|
||||||
<span class="ml-auto text-xs font-mono opacity-70">{paused_count}</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Inactive) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
|
||||||
size=ButtonSize::Sm
|
|
||||||
class="w-full justify-start gap-2"
|
|
||||||
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Inactive))
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
|
||||||
</svg>
|
|
||||||
"Inactive"
|
|
||||||
<span class="ml-auto text-xs font-mono opacity-70">{inactive_count}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
// Separator
|
||||||
|
<div class="border-t border-border" />
|
||||||
|
|
||||||
<div class="p-4 bg-card">
|
<div class="p-4 bg-card" style="padding-bottom: calc(1rem + env(safe-area-inset-bottom));">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Avatar class="h-8 w-8">
|
// Avatar
|
||||||
<AvatarFallback class="bg-primary text-primary-foreground text-xs font-medium">
|
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0">
|
||||||
{first_letter}
|
{first_letter}
|
||||||
</AvatarFallback>
|
</div>
|
||||||
</Avatar>
|
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden">
|
||||||
<div class="font-medium text-sm truncate text-foreground">{username}</div>
|
<div class="font-medium text-sm truncate text-foreground">{username}</div>
|
||||||
<div class="text-[10px] text-muted-foreground truncate">"Online"</div>
|
<div class="text-[10px] text-muted-foreground truncate">"Online"</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant=ButtonVariant::Ghost
|
// Theme toggle button
|
||||||
size=ButtonSize::Icon
|
<button
|
||||||
class="text-destructive h-8 w-8"
|
class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground hover:text-foreground transition-colors"
|
||||||
on_click=Callback::new(move |()| {
|
on:click=toggle_theme
|
||||||
|
>
|
||||||
|
<Show when=move || current_theme.get() == "dark" fallback=|| view! {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
// Logout button
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent text-destructive transition-colors"
|
||||||
|
on:click=move |_| {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if shared::server_fns::auth::logout().await.is_ok() {
|
if shared::server_fns::auth::logout().await.is_ok() {
|
||||||
let window = web_sys::window().expect("window should exist");
|
let window = web_sys::window().expect("window should exist");
|
||||||
let _ = window.location().set_href("/login");
|
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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn SidebarButton(
|
||||||
|
active: Signal<bool>,
|
||||||
|
on_click: impl Fn(web_sys::MouseEvent) + 'static,
|
||||||
|
#[prop(into)] icon: String,
|
||||||
|
#[prop(into)] label: &'static str,
|
||||||
|
count: Signal<usize>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
class=move || if active.get() {
|
||||||
|
"inline-flex items-center justify-start gap-2 w-full h-8 rounded-md px-3 text-sm font-medium bg-secondary text-secondary-foreground transition-colors"
|
||||||
|
} else {
|
||||||
|
"inline-flex items-center justify-start gap-2 w-full h-8 rounded-md px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
|
}
|
||||||
|
on:click=on_click
|
||||||
|
>
|
||||||
|
<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=icon.clone() />
|
||||||
|
</svg>
|
||||||
|
{label}
|
||||||
|
<span class="ml-auto text-xs font-mono opacity-70">{count}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::html;
|
use leptos::html;
|
||||||
use leptos_use::storage::use_local_storage;
|
|
||||||
use ::codee::string::FromToStringCodec;
|
|
||||||
use shared::GlobalLimitRequest;
|
use shared::GlobalLimitRequest;
|
||||||
use crate::api;
|
use crate::api;
|
||||||
|
|
||||||
@@ -30,27 +28,7 @@ pub fn StatusBar() -> 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 stats = store.global_stats;
|
let stats = store.global_stats;
|
||||||
|
|
||||||
let (current_theme, set_current_theme, _) = use_local_storage::<String, FromToStringCodec>("vibetorrent_theme");
|
|
||||||
|
|
||||||
// Initialize with default if empty
|
|
||||||
let current_theme_val = current_theme.get();
|
|
||||||
if current_theme_val.is_empty() {
|
|
||||||
set_current_theme.set("dark".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Automatically sync theme to document attribute
|
|
||||||
Effect::new(move |_| {
|
|
||||||
let theme = current_theme.get().to_lowercase();
|
|
||||||
if let Some(doc) = document().document_element() {
|
|
||||||
let _ = doc.set_attribute("data-theme", &theme);
|
|
||||||
// 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Preset limits in bytes/s
|
// Preset limits in bytes/s
|
||||||
let limits: Vec<(i64, &str)> = vec!(
|
let limits: Vec<(i64, &str)> = vec!(
|
||||||
@@ -91,7 +69,6 @@ pub fn StatusBar() -> impl IntoView {
|
|||||||
|
|
||||||
let down_details_ref = NodeRef::<html::Details>::new();
|
let down_details_ref = NodeRef::<html::Details>::new();
|
||||||
let up_details_ref = NodeRef::<html::Details>::new();
|
let up_details_ref = NodeRef::<html::Details>::new();
|
||||||
let theme_details_ref = NodeRef::<html::Details>::new();
|
|
||||||
|
|
||||||
let close_details = move |node_ref: NodeRef<html::Details>| {
|
let close_details = move |node_ref: NodeRef<html::Details>| {
|
||||||
if let Some(el) = node_ref.get_untracked() {
|
if let Some(el) = node_ref.get_untracked() {
|
||||||
@@ -201,49 +178,6 @@ pub fn StatusBar() -> impl IntoView {
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-4">
|
<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>
|
|
||||||
<button
|
<button
|
||||||
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"
|
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"
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_shadcn_input::Input;
|
|
||||||
use leptos_shadcn_button::{Button, ButtonVariant, ButtonSize};
|
|
||||||
use crate::components::torrent::add_torrent::AddTorrentDialog;
|
use crate::components::torrent::add_torrent::AddTorrentDialog;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
@@ -9,30 +7,36 @@ pub fn Toolbar() -> 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 is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
|
||||||
|
|
||||||
|
let search_value = RwSignal::new(String::new());
|
||||||
|
|
||||||
|
// Sync search_value to store
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let val = search_value.get();
|
||||||
|
store.search_query.set(val);
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);">
|
<div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);">
|
||||||
// Sol kısım: Menü butonu + Add Torrent
|
// Sol kısım: Menü butonu + Add Torrent
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
// Mobile Menu Trigger
|
// Mobile Menu Trigger
|
||||||
<Button
|
<button
|
||||||
variant=ButtonVariant::Ghost
|
class="inline-flex items-center justify-center size-9 rounded-md hover:bg-accent hover:text-accent-foreground lg:hidden"
|
||||||
size=ButtonSize::Icon
|
on:click=move |_| is_mobile_menu_open.update(|v| *v = !*v)
|
||||||
class="lg:hidden"
|
|
||||||
on_click=Callback::new(move |()| is_mobile_menu_open.update(|v| *v = !*v))
|
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
|
||||||
</Button>
|
</button>
|
||||||
|
|
||||||
<Button
|
<button
|
||||||
class="gap-2 shadow"
|
class="inline-flex items-center justify-center gap-2 h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all active:scale-[0.98]"
|
||||||
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>
|
||||||
|
|
||||||
// Sağ kısım: Search kutusu
|
// Sağ kısım: Search kutusu
|
||||||
@@ -42,12 +46,11 @@ pub fn Toolbar() -> impl IntoView {
|
|||||||
<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">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<Input
|
<input
|
||||||
input_type="search"
|
type="search"
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
value=MaybeProp::derive(move || Some(store.search_query.get()))
|
class="file:text-foreground placeholder:text-muted-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 md:text-sm pl-8"
|
||||||
on_change=Callback::new(move |val: String| store.search_query.set(val))
|
bind:value=search_value
|
||||||
class="pl-8 h-9"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod context_menu;
|
pub mod context_menu;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod toast;
|
|
||||||
pub mod torrent;
|
pub mod torrent;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
// pub mod toast; (Removed)
|
||||||
|
pub mod ui;
|
||||||
|
|||||||
@@ -1,86 +1,111 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
use shared::NotificationLevel;
|
use shared::NotificationLevel;
|
||||||
use leptos_shadcn_alert::{Alert, AlertVariant};
|
|
||||||
|
|
||||||
// ============================================================================
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
// Toast Components - Using ShadCN Alert
|
pub struct Toast {
|
||||||
// ============================================================================
|
pub id: String,
|
||||||
|
pub message: String,
|
||||||
|
pub level: NotificationLevel,
|
||||||
|
pub visible: RwSignal<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
fn level_to_variant(level: &NotificationLevel) -> AlertVariant {
|
#[derive(Clone, Copy)]
|
||||||
match level {
|
pub struct ToastContext {
|
||||||
NotificationLevel::Info => AlertVariant::Default,
|
pub toasts: RwSignal<HashMap<String, Toast>>,
|
||||||
NotificationLevel::Success => AlertVariant::Success,
|
}
|
||||||
NotificationLevel::Warning => AlertVariant::Warning,
|
|
||||||
NotificationLevel::Error => AlertVariant::Destructive,
|
impl ToastContext {
|
||||||
|
pub fn add(&self, message: impl Into<String>, level: NotificationLevel) {
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let message = message.into();
|
||||||
|
let toast = Toast {
|
||||||
|
id: id.clone(),
|
||||||
|
message,
|
||||||
|
level,
|
||||||
|
visible: RwSignal::new(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.toasts.update(|m| {
|
||||||
|
m.insert(id.clone(), toast);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto remove after 5 seconds
|
||||||
|
let toasts = self.toasts;
|
||||||
|
let id_clone = id.clone();
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
gloo_timers::future::TimeoutFuture::new(5000).await;
|
||||||
|
toasts.update(|m| {
|
||||||
|
if let Some(t) = m.get(&id_clone) {
|
||||||
|
t.visible.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Wait for animation
|
||||||
|
gloo_timers::future::TimeoutFuture::new(300).await;
|
||||||
|
toasts.update(|m| {
|
||||||
|
m.remove(&id_clone);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn level_icon(level: &NotificationLevel) -> impl IntoView {
|
pub fn provide_toast_context() {
|
||||||
match level {
|
let toasts = RwSignal::new(HashMap::new());
|
||||||
NotificationLevel::Info => view! {
|
provide_context(ToastContext { toasts });
|
||||||
<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(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Individual toast item component
|
|
||||||
#[component]
|
#[component]
|
||||||
fn ToastItem(
|
pub fn Toaster() -> impl IntoView {
|
||||||
level: NotificationLevel,
|
let context = expect_context::<ToastContext>();
|
||||||
message: String,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let variant = level_to_variant(&level);
|
|
||||||
let icon = level_icon(&level);
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Alert variant=variant class="pointer-events-auto shadow-lg">
|
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-sm pointer-events-none">
|
||||||
<div class="flex items-center gap-3">
|
{move || {
|
||||||
{icon}
|
context.toasts.get().into_values().map(|toast| {
|
||||||
<div class="text-sm font-medium">{message}</div>
|
view! { <ToastItem toast=toast /> }
|
||||||
</div>
|
}).collect::<Vec<_>>()
|
||||||
</Alert>
|
}}
|
||||||
}
|
</div>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/// Main toast container - renders all active notifications
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ToastContainer() -> impl IntoView {
|
fn ToastItem(toast: Toast) -> impl IntoView {
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("TorrentStore not provided");
|
let (visible, set_visible) = (toast.visible, toast.visible.write_only());
|
||||||
let notifications = store.notifications;
|
|
||||||
|
let base_classes = "pointer-events-auto relative w-full rounded-lg border p-4 shadow-lg transition-all duration-300 ease-in-out";
|
||||||
view! {
|
let color_classes = match toast.level {
|
||||||
<div
|
NotificationLevel::Success => "bg-green-50 text-green-900 border-green-200 dark:bg-green-900 dark:text-green-100 dark:border-green-800",
|
||||||
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"
|
NotificationLevel::Error => "bg-red-50 text-red-900 border-red-200 dark:bg-red-900 dark:text-red-100 dark:border-red-800",
|
||||||
>
|
NotificationLevel::Warning => "bg-yellow-50 text-yellow-900 border-yellow-200 dark:bg-yellow-900 dark:text-yellow-100 dark:border-yellow-800",
|
||||||
<For
|
NotificationLevel::Info => "bg-blue-50 text-blue-900 border-blue-200 dark:bg-blue-900 dark:text-blue-100 dark:border-blue-800",
|
||||||
each=move || notifications.get()
|
};
|
||||||
key=|item| item.id
|
|
||||||
children=move |item| {
|
view! {
|
||||||
view! {
|
<div
|
||||||
<ToastItem
|
class=move || format!("{} {} {}",
|
||||||
level=item.notification.level
|
base_classes,
|
||||||
message=item.notification.message
|
color_classes,
|
||||||
/>
|
if visible.get() { "opacity-100 translate-x-0" } else { "opacity-0 translate-x-full" }
|
||||||
}
|
)
|
||||||
}
|
role="alert"
|
||||||
/>
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium">{toast.message.clone()}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="inline-flex shrink-0 opacity-50 hover:opacity-100 focus:opacity-100 focus:outline-none"
|
||||||
|
on:click=move |_| set_visible.set(false)
|
||||||
|
>
|
||||||
|
<span class="sr-only">"Kapat"</span>
|
||||||
|
<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">
|
||||||
|
<line x1="18" x2="6" y1="6" y2="18"></line>
|
||||||
|
<line x1="6" x2="18" y1="6" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use leptos_shadcn_dialog::{Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter};
|
use crate::components::ui::input::{Input, InputType};
|
||||||
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 +8,15 @@ use crate::api;
|
|||||||
pub fn AddTorrentDialog(
|
pub fn AddTorrentDialog(
|
||||||
on_close: Callback<()>,
|
on_close: Callback<()>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let store = use_context::<TorrentStore>().expect("TorrentStore not provided");
|
let _store = use_context::<TorrentStore>().expect("TorrentStore not provided");
|
||||||
let notifications = store.notifications;
|
|
||||||
|
|
||||||
let uri = signal(String::new());
|
let uri = RwSignal::new(String::new());
|
||||||
let is_loading = signal(false);
|
let is_loading = signal(false);
|
||||||
let error_msg = signal(Option::<String>::None);
|
let error_msg = signal(Option::<String>::None);
|
||||||
let is_open = signal(true);
|
|
||||||
|
|
||||||
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.get();
|
||||||
|
|
||||||
if uri_val.is_empty() {
|
if uri_val.is_empty() {
|
||||||
error_msg.1.set(Some("Please enter a Magnet URI or URL".to_string()));
|
error_msg.1.set(Some("Please enter a Magnet URI or URL".to_string()));
|
||||||
@@ -36,12 +31,7 @@ pub fn AddTorrentDialog(
|
|||||||
match api::torrent::add(&uri_val).await {
|
match api::torrent::add(&uri_val).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::info!("Torrent added successfully");
|
log::info!("Torrent added successfully");
|
||||||
crate::store::show_toast_with_signal(
|
crate::store::toast_success("Torrent başarıyla eklendi");
|
||||||
notifications,
|
|
||||||
shared::NotificationLevel::Success,
|
|
||||||
"Torrent başarıyla eklendi"
|
|
||||||
);
|
|
||||||
is_open.1.set(false);
|
|
||||||
on_close.run(());
|
on_close.run(());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -53,58 +43,78 @@ pub fn AddTorrentDialog(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let handle_backdrop = {
|
||||||
|
let on_close = on_close.clone();
|
||||||
|
move |e: web_sys::MouseEvent| {
|
||||||
|
e.stop_propagation();
|
||||||
|
on_close.run(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Dialog
|
// Backdrop overlay
|
||||||
open=Signal::derive(move || is_open.0.get())
|
<div
|
||||||
on_open_change=Callback::new(move |open: bool| {
|
class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
is_open.1.set(open);
|
on:click=handle_backdrop
|
||||||
if !open {
|
/>
|
||||||
on_close.run(());
|
// Dialog panel
|
||||||
}
|
<div class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-lg sm:max-w-[425px]">
|
||||||
})
|
// Header
|
||||||
>
|
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
|
||||||
<DialogContent class="sm:max-w-[425px]">
|
<h2 class="text-lg font-semibold leading-none tracking-tight">"Add Torrent"</h2>
|
||||||
<DialogHeader>
|
<p class="text-sm text-muted-foreground">"Enter a Magnet link or a .torrent file URL."</p>
|
||||||
<DialogTitle>"Add Torrent"</DialogTitle>
|
</div>
|
||||||
<DialogDescription>"Enter a Magnet link or a .torrent file URL."</DialogDescription>
|
|
||||||
</DialogHeader>
|
<form on:submit=handle_submit class="space-y-4">
|
||||||
|
<Input
|
||||||
|
r#type=InputType::Text
|
||||||
|
placeholder="magnet:?xt=urn:btih:..."
|
||||||
|
bind_value=uri
|
||||||
|
disabled=is_loading.0.get()
|
||||||
|
/>
|
||||||
|
|
||||||
<form on:submit=handle_submit class="space-y-4">
|
{move || error_msg.0.get().map(|msg| view! {
|
||||||
<Input
|
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
input_type="text"
|
{msg}
|
||||||
placeholder="magnet:?xt=urn:btih:..."
|
</div>
|
||||||
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>
|
|
||||||
})}
|
|
||||||
|
|
||||||
<DialogFooter>
|
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
||||||
<Button
|
<button
|
||||||
variant=ButtonVariant::Ghost
|
type="button"
|
||||||
on_click=Callback::new(move |()| {
|
class="inline-flex items-center justify-center h-9 px-4 py-2 rounded-md text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
is_open.1.set(false);
|
on:click=move |_| on_close.run(())
|
||||||
on_close.run(());
|
>
|
||||||
|
"Cancel"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
disabled=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 {
|
||||||
"Cancel"
|
leptos::either::Either::Right(view! { "Add" })
|
||||||
</Button>
|
}}
|
||||||
<Button disabled=Signal::derive(move || is_loading.0.get())>
|
</button>
|
||||||
{move || if is_loading.0.get() {
|
</div>
|
||||||
leptos::either::Either::Left(view! { <span class="loading loading-spinner"></span> "Adding..." })
|
</form>
|
||||||
} else {
|
|
||||||
leptos::either::Either::Right(view! { "Add" })
|
// Close button (X)
|
||||||
}}
|
<button
|
||||||
</Button>
|
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none"
|
||||||
</DialogFooter>
|
on:click=move |_| on_close.run(())
|
||||||
</form>
|
>
|
||||||
</DialogContent>
|
<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">
|
||||||
</Dialog>
|
<path d="M18 6 6 18"></path>
|
||||||
|
<path d="m6 6 12 12"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">"Close"</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
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,3 +1,2 @@
|
|||||||
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::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use crate::store::{get_action_messages, show_toast_with_signal};
|
use crate::store::{get_action_messages, show_toast};
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use shared::NotificationLevel;
|
use shared::NotificationLevel;
|
||||||
use crate::components::context_menu::TorrentContextMenu;
|
use crate::components::context_menu::TorrentContextMenu;
|
||||||
use leptos_shadcn_card::{Card, CardHeader, CardTitle, CardContent};
|
use crate::components::ui::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"];
|
||||||
@@ -116,7 +116,6 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
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();
|
||||||
let notifications = store.notifications;
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let result = match action.as_str() {
|
let result = match action.as_str() {
|
||||||
"delete" => api::torrent::delete(&hash).await,
|
"delete" => api::torrent::delete(&hash).await,
|
||||||
@@ -126,8 +125,8 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
_ => api::torrent::action(&hash, &action).await,
|
_ => api::torrent::action(&hash, &action).await,
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => show_toast_with_signal(notifications, NotificationLevel::Success, success_msg),
|
Ok(_) => show_toast(NotificationLevel::Success, success_msg),
|
||||||
Err(e) => show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: {:?}", error_msg, e)),
|
Err(e) => show_toast(NotificationLevel::Error, format!("{}: {:?}", error_msg, e)),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
29
frontend/src/components/ui/button.rs
Normal file
29
frontend/src/components/ui/button.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_ui::variants;
|
||||||
|
|
||||||
|
variants! {
|
||||||
|
Button {
|
||||||
|
base: "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive w-fit hover:cursor-pointer active:scale-[0.98] active:opacity-100 touch-manipulation [-webkit-tap-highlight-color:transparent] select-none [-webkit-touch-callout:none]",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
Default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
Destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
Outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/5",
|
||||||
|
Secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
Ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
Link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
Default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
Sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
Lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
Icon: "size-9",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: {
|
||||||
|
element: button,
|
||||||
|
support_href: true,
|
||||||
|
support_aria_current: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/src/components/ui/card.rs
Normal file
15
frontend/src/components/ui/card.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_ui::clx;
|
||||||
|
|
||||||
|
mod components {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
clx! {Card, div, "bg-card text-card-foreground flex flex-col gap-4 rounded-xl border py-6 shadow-sm"}
|
||||||
|
clx! {CardHeader, div, "@container/card-header flex flex-col items-start gap-1.5 px-6 [.border-b]:pb-6"}
|
||||||
|
clx! {CardTitle, h2, "leading-none font-semibold"}
|
||||||
|
clx! {CardContent, div, "px-6"}
|
||||||
|
clx! {CardDescription, p, "text-muted-foreground text-sm"}
|
||||||
|
clx! {CardFooter, footer, "flex items-center px-6 [.border-t]:pt-6", "gap-2"}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub use components::*;
|
||||||
98
frontend/src/components/ui/input.rs
Normal file
98
frontend/src/components/ui/input.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
use leptos::html;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use strum::AsRefStr;
|
||||||
|
use tw_merge::tw_merge;
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Copy, PartialEq, Eq, AsRefStr)]
|
||||||
|
#[strum(serialize_all = "lowercase")]
|
||||||
|
pub enum InputType {
|
||||||
|
#[default]
|
||||||
|
Text,
|
||||||
|
Email,
|
||||||
|
Password,
|
||||||
|
Number,
|
||||||
|
Tel,
|
||||||
|
Url,
|
||||||
|
Search,
|
||||||
|
Date,
|
||||||
|
Time,
|
||||||
|
#[strum(serialize = "datetime-local")]
|
||||||
|
DatetimeLocal,
|
||||||
|
Month,
|
||||||
|
Week,
|
||||||
|
Color,
|
||||||
|
File,
|
||||||
|
Hidden,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Input(
|
||||||
|
#[prop(into, optional)] class: String,
|
||||||
|
#[prop(default = InputType::default())] r#type: InputType,
|
||||||
|
#[prop(into, optional)] placeholder: Option<String>,
|
||||||
|
#[prop(into, optional)] name: Option<String>,
|
||||||
|
#[prop(into, optional)] id: Option<String>,
|
||||||
|
#[prop(into, optional)] title: Option<String>,
|
||||||
|
#[prop(optional)] disabled: bool,
|
||||||
|
#[prop(optional)] readonly: bool,
|
||||||
|
#[prop(optional)] required: bool,
|
||||||
|
#[prop(optional)] autofocus: bool,
|
||||||
|
#[prop(into, optional)] min: Option<String>,
|
||||||
|
#[prop(into, optional)] max: Option<String>,
|
||||||
|
#[prop(into, optional)] step: Option<String>,
|
||||||
|
#[prop(into, optional)] bind_value: Option<RwSignal<String>>,
|
||||||
|
#[prop(optional)] node_ref: NodeRef<html::Input>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let merged_class = tw_merge!(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50",
|
||||||
|
"focus-visible:ring-2",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
"read-only:bg-muted",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
let type_str = r#type.as_ref();
|
||||||
|
|
||||||
|
match bind_value {
|
||||||
|
Some(signal) => view! {
|
||||||
|
<input
|
||||||
|
data-name="Input"
|
||||||
|
type=type_str
|
||||||
|
class=merged_class
|
||||||
|
placeholder=placeholder
|
||||||
|
name=name
|
||||||
|
id=id
|
||||||
|
title=title
|
||||||
|
disabled=disabled
|
||||||
|
readonly=readonly
|
||||||
|
required=required
|
||||||
|
autofocus=autofocus
|
||||||
|
min=min
|
||||||
|
max=max
|
||||||
|
step=step
|
||||||
|
bind:value=signal
|
||||||
|
node_ref=node_ref
|
||||||
|
/>
|
||||||
|
}.into_any(),
|
||||||
|
None => view! {
|
||||||
|
<input
|
||||||
|
data-name="Input"
|
||||||
|
type=type_str
|
||||||
|
class=merged_class
|
||||||
|
placeholder=placeholder
|
||||||
|
name=name
|
||||||
|
id=id
|
||||||
|
title=title
|
||||||
|
disabled=disabled
|
||||||
|
readonly=readonly
|
||||||
|
required=required
|
||||||
|
autofocus=autofocus
|
||||||
|
min=min
|
||||||
|
max=max
|
||||||
|
step=step
|
||||||
|
node_ref=node_ref
|
||||||
|
/>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
}
|
||||||
4
frontend/src/components/ui/mod.rs
Normal file
4
frontend/src/components/ui/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod button;
|
||||||
|
pub mod card;
|
||||||
|
pub mod input;
|
||||||
|
pub mod toast;
|
||||||
222
frontend/src/components/ui/toast.rs
Normal file
222
frontend/src/components/ui/toast.rs
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use tw_merge::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
||||||
|
pub enum ToastType {
|
||||||
|
#[default]
|
||||||
|
Default,
|
||||||
|
Success,
|
||||||
|
Error,
|
||||||
|
Warning,
|
||||||
|
Info,
|
||||||
|
Loading,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
||||||
|
pub enum SonnerPosition {
|
||||||
|
TopLeft,
|
||||||
|
TopCenter,
|
||||||
|
TopRight,
|
||||||
|
#[default]
|
||||||
|
BottomRight,
|
||||||
|
BottomCenter,
|
||||||
|
BottomLeft,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
||||||
|
pub enum SonnerDirection {
|
||||||
|
TopDown,
|
||||||
|
#[default]
|
||||||
|
BottomUp,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct ToastData {
|
||||||
|
pub id: u64,
|
||||||
|
pub title: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub variant: ToastType,
|
||||||
|
pub duration: u64, // ms
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct ToasterStore {
|
||||||
|
pub toasts: RwSignal<Vec<ToastData>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SonnerTrigger(
|
||||||
|
#[prop(into, optional)] class: String,
|
||||||
|
#[prop(optional, default = ToastType::default())] variant: ToastType,
|
||||||
|
#[prop(into)] title: String,
|
||||||
|
description: Option<String>,
|
||||||
|
#[prop(into, optional)] position: String,
|
||||||
|
on_dismiss: Option<Callback<()>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let variant_classes = match variant {
|
||||||
|
ToastType::Default => "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
ToastType::Success => "bg-green-500 text-white hover:bg-green-600",
|
||||||
|
ToastType::Error => "bg-red-500 text-white shadow-xs hover:bg-red-600",
|
||||||
|
ToastType::Warning => "bg-yellow-500 text-white hover:bg-yellow-600",
|
||||||
|
ToastType::Info => "bg-blue-500 text-white shadow-xs hover:bg-blue-600",
|
||||||
|
ToastType::Loading => "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
};
|
||||||
|
|
||||||
|
let merged_class = tw_merge!(
|
||||||
|
"inline-flex flex-col items-start justify-center gap-1 min-w-[300px] rounded-md text-sm font-medium transition-all shadow-lg p-4 cursor-pointer pointer-events-auto border border-border/50",
|
||||||
|
variant_classes,
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only set position attribute if not empty
|
||||||
|
let position_attr = if position.is_empty() { None } else { Some(position) };
|
||||||
|
|
||||||
|
// Clone title for data attribute usage, original moved into view
|
||||||
|
let title_clone = title.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class=merged_class
|
||||||
|
data-name="SonnerTrigger"
|
||||||
|
data-variant=variant.to_string()
|
||||||
|
data-toast-title=title_clone
|
||||||
|
data-toast-position=position_attr
|
||||||
|
on:click=move |_| {
|
||||||
|
if let Some(cb) = on_dismiss {
|
||||||
|
cb.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="font-semibold">{title}</div>
|
||||||
|
{move || description.as_ref().map(|d| view! { <div class="text-xs opacity-90">{d.clone()}</div> })}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SonnerContainer(
|
||||||
|
children: Children,
|
||||||
|
#[prop(into, optional)] class: String,
|
||||||
|
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let merged_class = tw_merge!("toast__container fixed z-[9999] flex flex-col gap-2 p-4 outline-none pointer-events-none", class);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class=merged_class data-position=position.to_string()>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SonnerList(
|
||||||
|
children: Children,
|
||||||
|
#[prop(into, optional)] class: String,
|
||||||
|
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
|
||||||
|
#[prop(optional, default = SonnerDirection::default())] direction: SonnerDirection,
|
||||||
|
#[prop(into, default = "false".to_string())] expanded: String,
|
||||||
|
#[prop(into, optional)] style: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let merged_class = tw_merge!(
|
||||||
|
"contents",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class=merged_class
|
||||||
|
data-name="SonnerList"
|
||||||
|
data-sonner-toaster="true"
|
||||||
|
data-sonner-theme="light"
|
||||||
|
data-position=position.to_string()
|
||||||
|
data-expanded=expanded
|
||||||
|
data-direction=direction.to_string()
|
||||||
|
style=style
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn provide_toaster() {
|
||||||
|
let toasts = RwSignal::new(Vec::<ToastData>::new());
|
||||||
|
provide_context(ToasterStore { toasts });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
|
||||||
|
// Global store'u al
|
||||||
|
let store = use_context::<ToasterStore>().expect("Toaster context not found. Call provide_toaster() in App root.");
|
||||||
|
let toasts = store.toasts;
|
||||||
|
|
||||||
|
// Auto-derive direction from position
|
||||||
|
let direction = match position {
|
||||||
|
SonnerPosition::TopLeft | SonnerPosition::TopCenter | SonnerPosition::TopRight => SonnerDirection::TopDown,
|
||||||
|
_ => SonnerDirection::BottomUp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let container_class = match position {
|
||||||
|
SonnerPosition::TopLeft => "left-0 top-0 items-start",
|
||||||
|
SonnerPosition::TopRight => "right-0 top-0 items-end",
|
||||||
|
SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-0 items-center",
|
||||||
|
SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-0 items-center",
|
||||||
|
SonnerPosition::BottomLeft => "left-0 bottom-0 items-start",
|
||||||
|
SonnerPosition::BottomRight => "right-0 bottom-0 items-end",
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<SonnerContainer class=container_class position=position>
|
||||||
|
<SonnerList position=position direction=direction>
|
||||||
|
<For
|
||||||
|
each=move || toasts.get()
|
||||||
|
key=|toast| toast.id
|
||||||
|
children=move |toast| {
|
||||||
|
let id = toast.id;
|
||||||
|
view! {
|
||||||
|
<SonnerTrigger
|
||||||
|
variant=toast.variant
|
||||||
|
title=toast.title
|
||||||
|
description=toast.description
|
||||||
|
on_dismiss=Some(Callback::new(move |_| {
|
||||||
|
toasts.update(|vec| vec.retain(|t| t.id != id));
|
||||||
|
}))
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SonnerList>
|
||||||
|
</SonnerContainer>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global Helper Functions
|
||||||
|
pub fn toast(title: impl Into<String>, variant: ToastType) {
|
||||||
|
if let Some(store) = use_context::<ToasterStore>() {
|
||||||
|
let id = js_sys::Math::random().to_bits();
|
||||||
|
let new_toast = ToastData {
|
||||||
|
id,
|
||||||
|
title: title.into(),
|
||||||
|
description: None,
|
||||||
|
variant,
|
||||||
|
duration: 4000,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.toasts.update(|t| t.push(new_toast.clone()));
|
||||||
|
|
||||||
|
// Auto remove after duration
|
||||||
|
let duration = new_toast.duration;
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
gloo_timers::future::TimeoutFuture::new(duration as u32).await;
|
||||||
|
if let Some(store) = use_context::<ToasterStore>() {
|
||||||
|
store.toasts.update(|vec| vec.retain(|t| t.id != id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
gloo_console::warn!("ToasterStore not found. Make sure <Toaster /> is mounted.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toast_success(title: impl Into<String>) { toast(title, ToastType::Success); }
|
||||||
|
pub fn toast_error(title: impl Into<String>) { toast(title, ToastType::Error); }
|
||||||
|
pub fn toast_warning(title: impl Into<String>) { toast(title, ToastType::Warning); }
|
||||||
|
pub fn toast_info(title: impl Into<String>) { toast(title, ToastType::Info); }
|
||||||
@@ -2,45 +2,30 @@ use futures::StreamExt;
|
|||||||
use gloo_net::eventsource::futures::EventSource;
|
use gloo_net::eventsource::futures::EventSource;
|
||||||
use leptos::prelude::*;
|
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, Torrent};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use struct_patch::traits::Patch;
|
use struct_patch::traits::Patch;
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
use crate::components::ui::toast::{ToastType, toast};
|
||||||
pub struct NotificationItem {
|
|
||||||
pub id: u64,
|
|
||||||
pub notification: SystemNotification,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show_toast_with_signal(
|
|
||||||
notifications: RwSignal<Vec<NotificationItem>>,
|
|
||||||
level: NotificationLevel,
|
|
||||||
message: impl Into<String>,
|
|
||||||
) {
|
|
||||||
let id = js_sys::Date::now() as u64;
|
|
||||||
let notification = SystemNotification {
|
|
||||||
level,
|
|
||||||
message: message.into(),
|
|
||||||
};
|
|
||||||
let item = NotificationItem { id, notification };
|
|
||||||
|
|
||||||
notifications.update(|list| list.push(item));
|
|
||||||
|
|
||||||
leptos::prelude::set_timeout(
|
|
||||||
move || {
|
|
||||||
notifications.update(|list| list.retain(|i| i.id != id));
|
|
||||||
},
|
|
||||||
std::time::Duration::from_secs(5),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
|
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
|
||||||
if let Some(store) = use_context::<TorrentStore>() {
|
let msg = message.into();
|
||||||
show_toast_with_signal(store.notifications, level, message);
|
gloo_console::log!("TOAST CALL:", &msg, format!("{:?}", level));
|
||||||
}
|
log::info!("Displaying toast: [{:?}] {}", level, msg);
|
||||||
|
|
||||||
|
let variant = match level {
|
||||||
|
NotificationLevel::Success => ToastType::Success,
|
||||||
|
NotificationLevel::Error => ToastType::Error,
|
||||||
|
NotificationLevel::Warning => ToastType::Warning,
|
||||||
|
NotificationLevel::Info => ToastType::Info,
|
||||||
|
};
|
||||||
|
|
||||||
|
toast(msg, variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); }
|
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); }
|
||||||
pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); }
|
pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); }
|
||||||
|
|
||||||
@@ -67,7 +52,6 @@ pub struct TorrentStore {
|
|||||||
pub filter: RwSignal<FilterStatus>,
|
pub filter: RwSignal<FilterStatus>,
|
||||||
pub search_query: RwSignal<String>,
|
pub search_query: RwSignal<String>,
|
||||||
pub global_stats: RwSignal<GlobalStats>,
|
pub global_stats: RwSignal<GlobalStats>,
|
||||||
pub notifications: RwSignal<Vec<NotificationItem>>,
|
|
||||||
pub user: RwSignal<Option<String>>,
|
pub user: RwSignal<Option<String>>,
|
||||||
pub selected_torrent: RwSignal<Option<String>>,
|
pub selected_torrent: RwSignal<Option<String>>,
|
||||||
}
|
}
|
||||||
@@ -77,16 +61,14 @@ pub fn provide_torrent_store() {
|
|||||||
let filter = RwSignal::new(FilterStatus::All);
|
let filter = RwSignal::new(FilterStatus::All);
|
||||||
let search_query = RwSignal::new(String::new());
|
let search_query = RwSignal::new(String::new());
|
||||||
let global_stats = RwSignal::new(GlobalStats::default());
|
let global_stats = RwSignal::new(GlobalStats::default());
|
||||||
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 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, selected_torrent };
|
let store = TorrentStore { torrents, filter, search_query, global_stats, user, selected_torrent };
|
||||||
provide_context(store);
|
provide_context(store);
|
||||||
|
|
||||||
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;
|
||||||
let show_browser_notification = show_browser_notification.clone();
|
let show_browser_notification = show_browser_notification.clone();
|
||||||
@@ -112,7 +94,7 @@ pub fn provide_torrent_store() {
|
|||||||
got_first_message = true;
|
got_first_message = true;
|
||||||
backoff_ms = 1000;
|
backoff_ms = 1000;
|
||||||
if was_connected && disconnect_notified {
|
if was_connected && disconnect_notified {
|
||||||
show_toast_with_signal(notifications_for_sse, NotificationLevel::Success, "Sunucu bağlantısı yeniden kuruldu");
|
show_toast(NotificationLevel::Success, "Sunucu bağlantısı yeniden kuruldu");
|
||||||
disconnect_notified = false;
|
disconnect_notified = false;
|
||||||
}
|
}
|
||||||
was_connected = true;
|
was_connected = true;
|
||||||
@@ -149,7 +131,7 @@ pub fn provide_torrent_store() {
|
|||||||
}
|
}
|
||||||
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
|
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
|
||||||
AppEvent::Notification(n) => {
|
AppEvent::Notification(n) => {
|
||||||
show_toast_with_signal(notifications_for_sse, n.level.clone(), n.message.clone());
|
show_toast(n.level.clone(), n.message.clone());
|
||||||
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
|
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
|
||||||
show_browser_notification("VibeTorrent", &n.message);
|
show_browser_notification("VibeTorrent", &n.message);
|
||||||
}
|
}
|
||||||
@@ -164,14 +146,14 @@ pub fn provide_torrent_store() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if was_connected && !disconnect_notified {
|
if was_connected && !disconnect_notified {
|
||||||
show_toast_with_signal(notifications_for_sse, NotificationLevel::Warning, "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...");
|
show_toast(NotificationLevel::Warning, "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...");
|
||||||
disconnect_notified = true;
|
disconnect_notified = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
if was_connected && !disconnect_notified {
|
if was_connected && !disconnect_notified {
|
||||||
show_toast_with_signal(notifications_for_sse, NotificationLevel::Warning, "Sunucu bağlantısı kurulamıyor...");
|
show_toast(NotificationLevel::Warning, "Sunucu bağlantısı kurulamıyor...");
|
||||||
disconnect_notified = true;
|
disconnect_notified = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
use web_sys::{Notification, NotificationOptions};
|
use web_sys::{Notification, NotificationOptions};
|
||||||
use leptos::prelude::*;
|
|
||||||
use leptos_use::{use_web_notification, UseWebNotificationReturn, NotificationPermission};
|
|
||||||
|
|
||||||
/// Request browser notification permission from user
|
/// Request browser notification permission from user
|
||||||
pub async fn request_notification_permission() -> bool {
|
pub async fn request_notification_permission() -> bool {
|
||||||
|
if !is_notification_supported() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if let Ok(promise) = Notification::request_permission() {
|
if let Ok(promise) = Notification::request_permission() {
|
||||||
if let Ok(result) = wasm_bindgen_futures::JsFuture::from(promise).await {
|
if let Ok(result) = wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||||
return result.as_string().unwrap_or_default() == "granted";
|
return result.as_string().unwrap_or_default() == "granted";
|
||||||
@@ -21,6 +23,9 @@ pub fn is_notification_supported() -> bool {
|
|||||||
|
|
||||||
/// Get current notification permission status
|
/// Get current notification permission status
|
||||||
pub fn get_notification_permission() -> String {
|
pub fn get_notification_permission() -> String {
|
||||||
|
if !is_notification_supported() {
|
||||||
|
return "denied".to_string();
|
||||||
|
}
|
||||||
match Notification::permission() {
|
match Notification::permission() {
|
||||||
web_sys::NotificationPermission::Granted => "granted".to_string(),
|
web_sys::NotificationPermission::Granted => "granted".to_string(),
|
||||||
web_sys::NotificationPermission::Denied => "denied".to_string(),
|
web_sys::NotificationPermission::Denied => "denied".to_string(),
|
||||||
@@ -32,8 +37,6 @@ pub fn get_notification_permission() -> String {
|
|||||||
/// Hook for using browser notifications within Leptos components or effects.
|
/// Hook for using browser notifications within Leptos components or effects.
|
||||||
/// This uses leptos-use for reactive permission tracking.
|
/// This uses leptos-use for reactive permission tracking.
|
||||||
pub fn use_app_notification() -> impl Fn(&str, &str) + Clone {
|
pub fn use_app_notification() -> impl Fn(&str, &str) + Clone {
|
||||||
let UseWebNotificationReturn { permission, .. } = use_web_notification();
|
|
||||||
|
|
||||||
move |title: &str, body: &str| {
|
move |title: &str, body: &str| {
|
||||||
// Check user preference from localStorage
|
// Check user preference from localStorage
|
||||||
let window = web_sys::window().expect("no global window");
|
let window = web_sys::window().expect("no global window");
|
||||||
@@ -42,8 +45,8 @@ pub fn use_app_notification() -> impl Fn(&str, &str) + Clone {
|
|||||||
.and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten())
|
.and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten())
|
||||||
.unwrap_or_else(|| "true".to_string());
|
.unwrap_or_else(|| "true".to_string());
|
||||||
|
|
||||||
// Use the reactive permission signal from leptos-use
|
// Check platform support and permission
|
||||||
if enabled == "true" && permission.get() == NotificationPermission::Granted {
|
if enabled == "true" && is_notification_supported() && get_notification_permission() == "granted" {
|
||||||
show_browser_notification(title, body);
|
show_browser_notification(title, body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const cargoRegistry = path.join(
|
|||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
darkMode: "class",
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{rs,html}",
|
"./src/**/*.{rs,html}",
|
||||||
@@ -25,4 +26,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
plugins: [
|
||||||
|
require("tailwindcss-animate"),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
2
frontend/ui_config.toml
Normal file
2
frontend/ui_config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
base_color = "neutral"
|
||||||
|
base_path_components = "src/components"
|
||||||
@@ -8,16 +8,17 @@ serde = { version = "1.0", features = ["derive"] }
|
|||||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||||
struct-patch = "0.5"
|
struct-patch = "0.5"
|
||||||
rmp-serde = "1.3"
|
rmp-serde = "1.3"
|
||||||
|
bytes = "1"
|
||||||
|
http = "1"
|
||||||
|
|
||||||
# Leptos 0.8.7
|
# Leptos 0.8.7
|
||||||
leptos = { version = "0.8.7", features = ["nightly"] }
|
leptos = { version = "0.8.15", features = ["nightly", "msgpack"] }
|
||||||
leptos_router = { version = "0.8.7", features = ["nightly"] }
|
leptos_router = { version = "0.8.7", features = ["nightly"] }
|
||||||
leptos_axum = { version = "0.8.7", optional = true }
|
leptos_axum = { version = "0.8.7", optional = true }
|
||||||
axum = { version = "0.8", features = ["macros"], optional = true }
|
axum = { version = "0.8", features = ["macros"], optional = true }
|
||||||
|
|
||||||
# SSR Dependencies (XML-RPC & SCGI)
|
# SSR Dependencies (XML-RPC & SCGI)
|
||||||
tokio = { version = "1", features = ["full"], optional = true }
|
tokio = { version = "1", features = ["full"], optional = true }
|
||||||
bytes = { version = "1", optional = true }
|
|
||||||
thiserror = { version = "2", optional = true }
|
thiserror = { version = "2", optional = true }
|
||||||
quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = true }
|
quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = true }
|
||||||
|
|
||||||
@@ -34,7 +35,6 @@ bcrypt = { version = "0.17", optional = true }
|
|||||||
default = []
|
default = []
|
||||||
ssr = [
|
ssr = [
|
||||||
"dep:tokio",
|
"dep:tokio",
|
||||||
"dep:bytes",
|
|
||||||
"dep:thiserror",
|
"dep:thiserror",
|
||||||
"dep:quick-xml",
|
"dep:quick-xml",
|
||||||
"dep:leptos_axum",
|
"dep:leptos_axum",
|
||||||
|
|||||||
1
shared/src/codec.rs
Normal file
1
shared/src/codec.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub use leptos::server_fn::codec::MsgPack;
|
||||||
@@ -11,6 +11,8 @@ pub mod xmlrpc;
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
|
||||||
|
pub mod codec;
|
||||||
|
|
||||||
pub mod server_fns;
|
pub mod server_fns;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::codec::MsgPack;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct UserResponse {
|
pub struct UserResponse {
|
||||||
@@ -19,7 +20,7 @@ pub struct SetupStatus {
|
|||||||
pub completed: bool,
|
pub completed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(GetSetupStatus, "/api/server_fns/GetSetupStatus")]
|
#[server(GetSetupStatus, "/api/server_fns/GetSetupStatus", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
|
pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
|
||||||
use crate::DbContext;
|
use crate::DbContext;
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(Setup, "/api/server_fns/Setup")]
|
#[server(Setup, "/api/server_fns/Setup", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> {
|
pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> {
|
||||||
use crate::DbContext;
|
use crate::DbContext;
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ pub async fn setup(username: String, password: String) -> Result<(), ServerFnErr
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(Login, "/api/server_fns/Login")]
|
#[server(Login, "/api/server_fns/Login", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn login(username: String, password: String) -> Result<UserResponse, ServerFnError> {
|
pub async fn login(username: String, password: String) -> Result<UserResponse, ServerFnError> {
|
||||||
use crate::DbContext;
|
use crate::DbContext;
|
||||||
use leptos_axum::ResponseOptions;
|
use leptos_axum::ResponseOptions;
|
||||||
@@ -110,7 +111,7 @@ pub async fn login(username: String, password: String) -> Result<UserResponse, S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(Logout, "/api/server_fns/Logout")]
|
#[server(Logout, "/api/server_fns/Logout", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn logout() -> Result<(), ServerFnError> {
|
pub async fn logout() -> Result<(), ServerFnError> {
|
||||||
use leptos_axum::ResponseOptions;
|
use leptos_axum::ResponseOptions;
|
||||||
use cookie::{Cookie, SameSite};
|
use cookie::{Cookie, SameSite};
|
||||||
@@ -131,7 +132,7 @@ pub async fn logout() -> Result<(), ServerFnError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(GetUser, "/api/server_fns/GetUser")]
|
#[server(GetUser, "/api/server_fns/GetUser", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn get_user() -> Result<Option<UserResponse>, ServerFnError> {
|
pub async fn get_user() -> Result<Option<UserResponse>, ServerFnError> {
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
use leptos_axum::extract;
|
use leptos_axum::extract;
|
||||||
|
|||||||
Reference in New Issue
Block a user