Compare commits
49 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
945f4718eb | ||
|
|
6a2952c6f3 | ||
|
|
03b63dd5d0 | ||
|
|
7717dffc56 | ||
|
|
3a2cab7ca7 | ||
|
|
e0b5411eb1 | ||
|
|
f85adfa007 | ||
|
|
88c3cd57c1 | ||
|
|
d67215a6eb | ||
|
|
5cc2fdd8b4 | ||
|
|
38bce3fecf | ||
|
|
f1c75c468a | ||
|
|
bfb152f0d8 | ||
|
|
8a7d9957aa | ||
|
|
56e8cc03d1 | ||
|
|
04cb7d51cb | ||
|
|
555505b80e | ||
|
|
fa07fd88dc | ||
|
|
bbb8e8dc98 | ||
|
|
d09ecd21b7 | ||
|
|
9a00e341af | ||
|
|
c78dcda55e | ||
|
|
57abbb3335 | ||
|
|
315a2421c4 | ||
|
|
c135c96d27 | ||
|
|
315a2f9a53 | ||
|
|
9d160a7ef5 | ||
|
|
a24e4101e8 | ||
|
|
7539307e18 | ||
|
|
907ae66a7f | ||
|
|
f35b119c0d | ||
|
|
920704ee72 | ||
|
|
d8ad9e62d8 | ||
|
|
ea99ac62bc | ||
|
|
af13b5af09 | ||
|
|
c8907e7999 | ||
|
|
714e2cb7d5 | ||
|
|
f35b716f93 | ||
|
|
47db9fa0c0 | ||
|
|
47dc4da6d1 | ||
|
|
c501ed9207 | ||
|
|
4861faee18 | ||
|
|
6a4943d692 | ||
|
|
b27caa77f2 | ||
|
|
cba8c20d9b | ||
|
|
0cdd92dc95 | ||
|
|
b9798ce0e2 | ||
|
|
6a882b75b6 | ||
|
|
40c9f66e5c |
433
Cargo.lock
generated
433
Cargo.lock
generated
@@ -1256,30 +1256,14 @@ dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"console_log",
|
||||
"futures",
|
||||
"gloo-console",
|
||||
"gloo-net",
|
||||
"gloo-timers",
|
||||
"js-sys",
|
||||
"leptos",
|
||||
"leptos-shadcn-alert",
|
||||
"leptos-shadcn-avatar",
|
||||
"leptos-shadcn-badge",
|
||||
"leptos-shadcn-button",
|
||||
"leptos-shadcn-card",
|
||||
"leptos-shadcn-context-menu",
|
||||
"leptos-shadcn-dialog",
|
||||
"leptos-shadcn-dropdown-menu",
|
||||
"leptos-shadcn-input",
|
||||
"leptos-shadcn-label",
|
||||
"leptos-shadcn-progress",
|
||||
"leptos-shadcn-scroll-area",
|
||||
"leptos-shadcn-separator",
|
||||
"leptos-shadcn-sheet",
|
||||
"leptos-shadcn-skeleton",
|
||||
"leptos-shadcn-tabs",
|
||||
"leptos-shadcn-toast",
|
||||
"leptos-shadcn-tooltip",
|
||||
"leptos-use",
|
||||
"leptos_router",
|
||||
"leptos_ui",
|
||||
"log",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
@@ -1287,8 +1271,10 @@ dependencies = [
|
||||
"serde_json",
|
||||
"shared",
|
||||
"struct-patch",
|
||||
"strum",
|
||||
"tailwind_fuse",
|
||||
"thiserror 2.0.18",
|
||||
"tw_merge",
|
||||
"uuid",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
@@ -1440,6 +1426,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "gloo-net"
|
||||
version = "0.6.0"
|
||||
@@ -2156,334 +2155,6 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-node-ref"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f57b1ebc451fe9e7b6c7eba680fa8bc7313b410cc6c0f18481cb55a60ff3ac6"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"send_wrapper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-alert"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "884551df61ade405bdb61b0d5a92a3a88b3a7af7a2d283b8d3e942ae0d71309c"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-avatar"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8cb3c5b1f5ba02f7282b55fde1513cdfecef3b25bf5fa44e1eb29fcaf8b927c5"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-badge"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24578fb0bc21eb21be4e686e6719c7e183acb8fd071a4f81fb27fe452751c88a"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-button"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6d1a7b813b726be7920f7238c127a14129ba4a45fa879312cad3ed2f8a1745"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-card"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b5cda16742d1e20284e5f6805eab88b6e54c1378d1548a8e15a5eedda1ea3eb"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-context-menu"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0f440e9a7517dfe6ba758080ddba1dfe42e4697008f60adfc112c5da02dca8d"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-dialog"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3fdbb636393b150c2db1e37d44a6832e9dde177ce2e81281932fefad8bde98e"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-dropdown-menu"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f50189623a176386a30443d281483c5aa6cc34dc45fa11c3e53bd187ffccde21"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-input"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0939cdad5a878d920decda39a4b42ecf4eba15736a92bbd73b1b408807899b8"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"regex",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-label"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e7cad4b5fae11df9bf3b1d4265a56509a9bb7d3a8580e7487f398b733eadf0c"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-progress"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a34ca41b8ebfd7f29126e4f8656987834f3613717016f11f3983da85a90669f6"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-scroll-area"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef3d7bdcae4919ad495529ec2a5974036fb0b959580df310f36b2fd33f90860c"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-separator"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5dfda49f059fd4d1549d663e6743e37a5c6c84d1ac2d6daec32caa3156bc268"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-sheet"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba85819a0c94a7705ed92989442c64cc75d9ed3a4540e711e87c56b206431611"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-signal-management"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5097c5171eb0be12bbf8fd736f4e669012657112865506a825480f2b013f6de"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"js-sys",
|
||||
"leptos",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-skeleton"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c14b6bd0f2fe191e3e114a34cee889fc983546ad488e76e76511e3d75ea3f86"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-tabs"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39f817c834e70a8359933b7b274564313be64105370611af96f05508541b661b"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-toast"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3315f7ed844e3286704cc7b57db7209cad592c11eee770f5dc48ebdc92d66cfb"
|
||||
dependencies = [
|
||||
"gloo-timers",
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"uuid",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-shadcn-tooltip"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e41d37932d700444e1d3a21f10f198c3c9e76dde3fd78d58da4b5a099939fd7"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-node-ref",
|
||||
"leptos-shadcn-signal-management",
|
||||
"leptos-struct-component",
|
||||
"leptos-style",
|
||||
"tailwind_fuse",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-struct-component"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c32085b37b67e61e69e0949d94e36c40e4fde83867681cbb884f9cd40a43881e"
|
||||
dependencies = [
|
||||
"leptos",
|
||||
"leptos-struct-component-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-struct-component-macro"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a40efd792acc28a115605b84ecb39e89397a278950bc8f2aad1bdcc7af2033af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-style"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c65408961a0bd8e70f317de8973d532a0cb9ffbac910c488d97f9c5a2e4411e2"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"leptos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-use"
|
||||
version = "0.16.3"
|
||||
@@ -2689,6 +2360,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "libc"
|
||||
version = "0.2.180"
|
||||
@@ -3981,6 +3663,7 @@ dependencies = [
|
||||
"inventory",
|
||||
"js-sys",
|
||||
"pin-project-lite",
|
||||
"rmp-serde",
|
||||
"rustc_version",
|
||||
"rustversion",
|
||||
"send_wrapper",
|
||||
@@ -4066,6 +3749,7 @@ dependencies = [
|
||||
"bcrypt",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"http 1.4.0",
|
||||
"jsonwebtoken",
|
||||
"leptos",
|
||||
"leptos_axum",
|
||||
@@ -4447,6 +4131,28 @@ dependencies = [
|
||||
"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]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -4557,19 +4263,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ca71fb01735fbc6fa13e9390d7a3037dde97053c0b65c0c72c0159cd009d26b"
|
||||
dependencies = [
|
||||
"nom",
|
||||
"tailwind_fuse_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tailwind_fuse_macro"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efa51b9ff80b5533001f8452d254a688bc7bb39c6bb77f9e0a19c1664d035888"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4994,6 +4687,28 @@ dependencies = [
|
||||
"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]]
|
||||
name = "typed-builder"
|
||||
version = "0.21.2"
|
||||
|
||||
@@ -1,154 +1,2 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{State, Json},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use time::Duration;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
username: String,
|
||||
password: String,
|
||||
#[serde(default)]
|
||||
remember_me: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserResponse {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/login",
|
||||
request_body = LoginRequest,
|
||||
responses(
|
||||
(status = 200, description = "Login successful"),
|
||||
(status = 401, description = "Invalid credentials"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn login_handler(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> impl IntoResponse {
|
||||
tracing::info!("Login attempt for user: {}", payload.username);
|
||||
|
||||
let user = match state.db.get_user_by_username(&payload.username).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
tracing::warn!("Login failed: User not found for {}", payload.username);
|
||||
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("DB error during login for {}: {}", payload.username, e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let (user_id, password_hash) = user;
|
||||
|
||||
match bcrypt::verify(&payload.password, &password_hash) {
|
||||
Ok(true) => {
|
||||
tracing::info!("Password verified for user: {}", payload.username);
|
||||
|
||||
// Create session
|
||||
let token: String = (0..32).map(|_| {
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
rand::thread_rng().sample(Alphanumeric) as char
|
||||
}).collect();
|
||||
|
||||
// Expiration: 30 days if remember_me is true, else 1 day
|
||||
let expires_in = if payload.remember_me {
|
||||
60 * 60 * 24 * 30
|
||||
} else {
|
||||
60 * 60 * 24
|
||||
};
|
||||
|
||||
let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + expires_in;
|
||||
|
||||
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
|
||||
tracing::error!("Failed to create session for {}: {}", payload.username, e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create session").into_response();
|
||||
}
|
||||
|
||||
let mut cookie = Cookie::build(("auth_token", token))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.build();
|
||||
|
||||
cookie.set_max_age(Duration::seconds(expires_in));
|
||||
|
||||
tracing::info!("Session created and cookie set for user: {}", payload.username);
|
||||
(StatusCode::OK, jar.add(cookie), Json(UserResponse { username: payload.username })).into_response()
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::warn!("Login failed: Invalid password for {}", payload.username);
|
||||
(StatusCode::UNAUTHORIZED, "Invalid credentials").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Bcrypt error for {}: {}", payload.username, e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Auth error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/logout",
|
||||
responses(
|
||||
(status = 200, description = "Logged out")
|
||||
)
|
||||
)]
|
||||
pub async fn logout_handler(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(token) = jar.get("auth_token") {
|
||||
let _ = state.db.delete_session(token.value()).await;
|
||||
}
|
||||
|
||||
let cookie = Cookie::build(("auth_token", ""))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.max_age(Duration::seconds(-1)) // Expire immediately
|
||||
.build();
|
||||
|
||||
(StatusCode::OK, jar.add(cookie), "Logged out").into_response()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/check",
|
||||
responses(
|
||||
(status = 200, description = "Authenticated", body = UserResponse),
|
||||
(status = 401, description = "Not authenticated")
|
||||
)
|
||||
)]
|
||||
pub async fn check_auth_handler(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(token) = jar.get("auth_token") {
|
||||
match state.db.get_session_user(token.value()).await {
|
||||
Ok(Some(user_id)) => {
|
||||
// Fetch username
|
||||
// We need a helper in db.rs to get username by id, or we can use a direct query here if we don't want to change db.rs interface yet.
|
||||
// But better to add `get_username_by_id` to db.rs
|
||||
// For now let's query directly or via a new db method.
|
||||
if let Ok(Some(username)) = state.db.get_username_by_id(user_id).await {
|
||||
return (StatusCode::OK, Json(UserResponse { username })).into_response();
|
||||
}
|
||||
},
|
||||
_ => {} // Invalid session
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::UNAUTHORIZED.into_response()
|
||||
}
|
||||
// This file is intentionally empty as authentication is now handled by Server Functions.
|
||||
// See shared/src/server_fns/auth.rs
|
||||
|
||||
@@ -1,125 +1,2 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{State, Json},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use time::Duration;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SetupRequest {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SetupStatusResponse {
|
||||
completed: bool,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/setup/status",
|
||||
responses(
|
||||
(status = 200, description = "Setup status", body = SetupStatusResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_setup_status_handler(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let completed = match state.db.has_users().await {
|
||||
Ok(has) => has,
|
||||
Err(e) => {
|
||||
tracing::error!("DB error checking users: {}", e);
|
||||
false
|
||||
}
|
||||
};
|
||||
Json(SetupStatusResponse { completed }).into_response()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/setup",
|
||||
request_body = SetupRequest,
|
||||
responses(
|
||||
(status = 200, description = "Setup completed and logged in"),
|
||||
(status = 400, description = "Invalid request"),
|
||||
(status = 403, description = "Setup already completed"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn setup_handler(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Json(payload): Json<SetupRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// 1. Check if setup is already completed (i.e., users exist)
|
||||
match state.db.has_users().await {
|
||||
Ok(true) => return (StatusCode::FORBIDDEN, "Setup already completed").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("DB error checking users: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
|
||||
}
|
||||
Ok(false) => {} // Proceed
|
||||
}
|
||||
|
||||
// 2. Validate input
|
||||
if payload.username.len() < 3 || payload.password.len() < 6 {
|
||||
return (StatusCode::BAD_REQUEST, "Username must be at least 3 chars, password at least 6").into_response();
|
||||
}
|
||||
|
||||
// 3. Create User
|
||||
// Lower cost for faster login on low-power devices (MIPS routers etc.)
|
||||
let password_hash = match bcrypt::hash(&payload.password, 6) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to hash password: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to process password").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = state.db.create_user(&payload.username, &password_hash).await {
|
||||
tracing::error!("Failed to create user: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create user").into_response();
|
||||
}
|
||||
|
||||
// 4. Auto-Login (Create Session)
|
||||
// Get the created user's ID
|
||||
let user = match state.db.get_user_by_username(&payload.username).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return (StatusCode::INTERNAL_SERVER_ERROR, "User created but not found").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("DB error fetching new user: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
|
||||
}
|
||||
};
|
||||
let (user_id, _) = user;
|
||||
|
||||
// Create session token
|
||||
let token: String = (0..32).map(|_| {
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
rand::thread_rng().sample(Alphanumeric) as char
|
||||
}).collect();
|
||||
|
||||
// Default expiration: 1 day (since it's not "remember me")
|
||||
let expires_in = 60 * 60 * 24;
|
||||
let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + expires_in;
|
||||
|
||||
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
|
||||
tracing::error!("Failed to create session for new user: {}", e);
|
||||
// Even if session fails, setup is technically complete, but login failed.
|
||||
// We return OK but user will have to login manually.
|
||||
return (StatusCode::OK, "Setup completed, please login").into_response();
|
||||
}
|
||||
|
||||
let mut cookie = Cookie::build(("auth_token", token))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.build();
|
||||
|
||||
cookie.set_max_age(Duration::seconds(expires_in));
|
||||
|
||||
(StatusCode::OK, jar.add(cookie), "Setup completed and logged in").into_response()
|
||||
}
|
||||
// This file is intentionally empty as setup is now handled by Server Functions.
|
||||
// See shared/src/server_fns/auth.rs
|
||||
|
||||
@@ -25,7 +25,6 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use tower::ServiceBuilder;
|
||||
use tower_governor::GovernorLayer;
|
||||
use tower_http::{
|
||||
compression::{CompressionLayer, CompressionLevel},
|
||||
cors::CorsLayer,
|
||||
@@ -48,23 +47,27 @@ pub struct AppState {
|
||||
}
|
||||
|
||||
async fn auth_middleware(
|
||||
state: axum::extract::State<AppState>,
|
||||
_state: axum::extract::State<AppState>,
|
||||
jar: CookieJar,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
// Skip auth for public paths
|
||||
// Skip auth for public server functions
|
||||
let path = request.uri().path();
|
||||
if path.starts_with("/api/server_fns/Login") // Login server fn
|
||||
if path.starts_with("/api/server_fns/Login")
|
||||
|| path.starts_with("/api/server_fns/login")
|
||||
|| path.starts_with("/api/server_fns/GetSetupStatus")
|
||||
|| path.starts_with("/api/server_fns/get_setup_status")
|
||||
|| path.starts_with("/api/server_fns/Setup")
|
||||
|| path.starts_with("/api/server_fns/setup")
|
||||
|| path.starts_with("/swagger-ui")
|
||||
|| path.starts_with("/api-docs")
|
||||
|| !path.starts_with("/api/") // Allow static files (frontend)
|
||||
|| !path.starts_with("/api/")
|
||||
{
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
|
||||
// Check token
|
||||
if let Some(token) = jar.get("auth_token") {
|
||||
use jsonwebtoken::{decode, Validation, DecodingKey};
|
||||
@@ -113,13 +116,6 @@ struct Args {
|
||||
#[cfg(feature = "swagger")]
|
||||
#[derive(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(
|
||||
schemas(
|
||||
shared::AddTorrentRequest,
|
||||
@@ -132,10 +128,6 @@ struct Args {
|
||||
shared::SetFilePriorityRequest,
|
||||
shared::SetLabelRequest,
|
||||
shared::GlobalLimitRequest,
|
||||
handlers::auth::LoginRequest,
|
||||
handlers::setup::SetupRequest,
|
||||
handlers::setup::SetupStatusResponse,
|
||||
handlers::auth::UserResponse
|
||||
)
|
||||
),
|
||||
tags(
|
||||
@@ -144,6 +136,7 @@ struct Args {
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Load .env file
|
||||
@@ -232,6 +225,18 @@ async fn main() {
|
||||
tracing::info!("Socket: {}", args.socket);
|
||||
tracing::info!("Port: {}", args.port);
|
||||
|
||||
// Force linking of server functions from shared crate for registration on Mac
|
||||
{
|
||||
use shared::server_fns::auth::*;
|
||||
let _ = get_setup_status;
|
||||
let _ = setup;
|
||||
let _ = login;
|
||||
let _ = logout;
|
||||
let _ = get_user;
|
||||
tracing::info!("Server functions linked successfully.");
|
||||
}
|
||||
|
||||
|
||||
// ... rest of the main function ...
|
||||
// Startup Health Check
|
||||
let socket_path = std::path::Path::new(&args.socket);
|
||||
@@ -423,6 +428,7 @@ async fn main() {
|
||||
#[cfg(feature = "swagger")]
|
||||
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)
|
||||
let scgi_path_for_ctx = args.socket.clone();
|
||||
let db_for_ctx = db.clone();
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
use governor::clock::QuantaInstant;
|
||||
use governor::middleware::NoOpMiddleware;
|
||||
use tower_governor::governor::GovernorConfig;
|
||||
use tower_governor::governor::GovernorConfigBuilder;
|
||||
use tower_governor::key_extractor::SmartIpKeyExtractor;
|
||||
|
||||
pub fn get_login_rate_limit_config() -> GovernorConfig<SmartIpKeyExtractor, NoOpMiddleware<QuantaInstant>> {
|
||||
// 5 yanlış denemeden sonra bloklanır.
|
||||
// Her yeni hak için 60 saniye (1 dakika) bekleme süresi.
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.per_second(60)
|
||||
.burst_size(5)
|
||||
.finish()
|
||||
.unwrap()
|
||||
}
|
||||
// This file can be removed or repurposed if rate limiting is needed for other endpoints.
|
||||
// Login rate limiting is now handled within the server function or needs to be reimplemented
|
||||
// as a middleware for the server function endpoint.
|
||||
|
||||
@@ -4,7 +4,7 @@ use shared::xmlrpc::{
|
||||
use crate::AppState;
|
||||
use axum::extract::State;
|
||||
use axum::response::sse::{Event, Sse};
|
||||
use futures::stream::{self, Stream};
|
||||
use futures::stream::{self};
|
||||
use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus};
|
||||
use std::convert::Infallible;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
@@ -7,7 +7,7 @@ edition = "2021"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
leptos = { version = "0.8.15", features = ["csr"] }
|
||||
leptos = { version = "0.8.15", features = ["csr", "msgpack", "nightly"] }
|
||||
leptos_router = { version = "0.8.11" }
|
||||
|
||||
console_error_panic_hook = "0.1"
|
||||
@@ -17,6 +17,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
gloo-net = "0.6"
|
||||
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||
gloo-console = "0.3"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
uuid = { version = "1", features = ["v4", "js"] }
|
||||
@@ -34,22 +35,11 @@ thiserror = "2.0"
|
||||
rmp-serde = "1.3"
|
||||
struct-patch = "0.5"
|
||||
|
||||
# ShadCN UI Components (Individual)
|
||||
leptos-shadcn-button = "0.8"
|
||||
leptos-shadcn-input = "0.8"
|
||||
leptos-shadcn-card = "0.8"
|
||||
leptos-shadcn-badge = "0.8"
|
||||
leptos-shadcn-context-menu = "0.8"
|
||||
leptos-shadcn-separator = "0.8"
|
||||
leptos-shadcn-progress = "0.8"
|
||||
leptos-shadcn-avatar = "0.8"
|
||||
leptos-shadcn-sheet = "0.8"
|
||||
leptos-shadcn-tabs = "0.8"
|
||||
leptos-shadcn-scroll-area = "0.8"
|
||||
leptos-shadcn-dialog = "0.8"
|
||||
leptos-shadcn-label = "0.8"
|
||||
leptos-shadcn-alert = "0.8"
|
||||
leptos-shadcn-toast = "0.8"
|
||||
leptos-shadcn-dropdown-menu = "0.8"
|
||||
leptos-shadcn-tooltip = "0.8"
|
||||
leptos-shadcn-skeleton = "0.8"
|
||||
# Rust/UI Components
|
||||
leptos_ui = "0.3"
|
||||
tw_merge = "0.1"
|
||||
strum = { version = "0.26", features = ["derive"] }
|
||||
icons = { version = "0.18.0", features = ["leptos"] }
|
||||
|
||||
[package.metadata.leptos]
|
||||
tailwind-input-file = "input.css"
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
<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="public/lock_scroll.js" />
|
||||
<script src="/lock_scroll.js"></script>
|
||||
<link data-trunk rel="copy-file" href="sw.js" />
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
1215
frontend/package-lock.json
generated
1215
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,29 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "tailwind.config.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"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",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-cli": "^11.0.1",
|
||||
"postcss-preset-env": "^11.1.3",
|
||||
"postcss-preset-env": "^10.1.3",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4.1.18"
|
||||
}
|
||||
"keywords": [],
|
||||
"license": "ISC",
|
||||
"main": "tailwind.config.js",
|
||||
"name": "frontend",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"type": "module",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
253
frontend/public/lock_scroll.js
Normal file
253
frontend/public/lock_scroll.js
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Scroll Lock Utility
|
||||
* Handles locking and unlocking scroll for both window and all scrollable containers
|
||||
* Similar to react-remove-scroll but in vanilla JavaScript
|
||||
*/
|
||||
|
||||
(() => {
|
||||
// Prevent multiple initializations
|
||||
if (window.ScrollLock) {
|
||||
return;
|
||||
}
|
||||
|
||||
class ScrollLock {
|
||||
constructor() {
|
||||
this.locked = false;
|
||||
this.scrollableElements = [];
|
||||
this.scrollPositions = new Map();
|
||||
this.originalStyles = new Map();
|
||||
this.fixedElements = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all scrollable elements in the DOM (optimized)
|
||||
* Uses more targeted selectors instead of querying all elements
|
||||
*/
|
||||
findScrollableElements() {
|
||||
const scrollables = [];
|
||||
|
||||
// More targeted query - only look for elements with overflow properties
|
||||
const candidates = document.querySelectorAll(
|
||||
'[style*="overflow"], [class*="overflow"], [class*="scroll"], main, aside, section, div',
|
||||
);
|
||||
|
||||
// Batch all style reads first to minimize reflows
|
||||
const elementsToCheck = [];
|
||||
for (const el of candidates) {
|
||||
// Skip the element itself or if it's inside these containers
|
||||
const dataName = el.getAttribute("data-name");
|
||||
const isExcludedElement =
|
||||
dataName === "ScrollArea" ||
|
||||
dataName === "CommandList" ||
|
||||
dataName === "SelectContent" ||
|
||||
dataName === "MultiSelectContent" ||
|
||||
dataName === "DropdownMenuContent" ||
|
||||
dataName === "ContextMenuContent";
|
||||
|
||||
if (
|
||||
el !== document.body &&
|
||||
el !== document.documentElement &&
|
||||
!isExcludedElement &&
|
||||
!el.closest('[data-name="ScrollArea"]') &&
|
||||
!el.closest('[data-name="CommandList"]') &&
|
||||
!el.closest('[data-name="SelectContent"]') &&
|
||||
!el.closest('[data-name="MultiSelectContent"]') &&
|
||||
!el.closest('[data-name="DropdownMenuContent"]') &&
|
||||
!el.closest('[data-name="ContextMenuContent"]')
|
||||
) {
|
||||
elementsToCheck.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
// Now batch read all computed styles and dimensions
|
||||
elementsToCheck.forEach((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const hasOverflow =
|
||||
style.overflow === "auto" ||
|
||||
style.overflow === "scroll" ||
|
||||
style.overflowY === "auto" ||
|
||||
style.overflowY === "scroll";
|
||||
|
||||
// Only check scrollHeight if overflow is set
|
||||
if (hasOverflow && el.scrollHeight > el.clientHeight) {
|
||||
scrollables.push(el);
|
||||
}
|
||||
});
|
||||
|
||||
return scrollables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock scrolling on all scrollable elements (optimized)
|
||||
* Batches all DOM reads before DOM writes to prevent forced reflows
|
||||
*/
|
||||
lock() {
|
||||
if (this.locked) return;
|
||||
|
||||
this.locked = true;
|
||||
|
||||
// Find all scrollable elements
|
||||
this.scrollableElements = this.findScrollableElements();
|
||||
|
||||
// ===== BATCH 1: READ PHASE - Read all layout properties first =====
|
||||
const windowScrollY = window.scrollY;
|
||||
const scrollbarWidth = window.innerWidth - document.body.clientWidth;
|
||||
|
||||
// Store window scroll position
|
||||
this.scrollPositions.set("window", windowScrollY);
|
||||
|
||||
// Store original body styles
|
||||
this.originalStyles.set("body", {
|
||||
position: document.body.style.position,
|
||||
top: document.body.style.top,
|
||||
width: document.body.style.width,
|
||||
overflow: document.body.style.overflow,
|
||||
paddingRight: document.body.style.paddingRight,
|
||||
});
|
||||
|
||||
// Read all fixed-position elements and their padding (only if we have scrollbar)
|
||||
if (scrollbarWidth > 0) {
|
||||
// Use more targeted query for fixed elements
|
||||
const fixedCandidates = document.querySelectorAll(
|
||||
'[style*="fixed"], [class*="fixed"], header, nav, aside, [role="dialog"], [role="alertdialog"]',
|
||||
);
|
||||
|
||||
this.fixedElements = Array.from(fixedCandidates).filter((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return (
|
||||
style.position === "fixed" &&
|
||||
!el.closest('[data-name="DropdownMenuContent"]') &&
|
||||
!el.closest('[data-name="MultiSelectContent"]') &&
|
||||
!el.closest('[data-name="ContextMenuContent"]')
|
||||
);
|
||||
});
|
||||
|
||||
// Batch read all padding values
|
||||
this.fixedElements.forEach((el) => {
|
||||
const computedStyle = window.getComputedStyle(el);
|
||||
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
|
||||
|
||||
this.originalStyles.set(el, {
|
||||
paddingRight: el.style.paddingRight,
|
||||
computedPadding: currentPadding,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Read scrollable elements info
|
||||
const scrollableInfo = this.scrollableElements.map((el) => {
|
||||
const scrollTop = el.scrollTop;
|
||||
const elementScrollbarWidth = el.offsetWidth - el.clientWidth;
|
||||
const computedStyle = window.getComputedStyle(el);
|
||||
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
|
||||
|
||||
this.scrollPositions.set(el, scrollTop);
|
||||
this.originalStyles.set(el, {
|
||||
overflow: el.style.overflow,
|
||||
overflowY: el.style.overflowY,
|
||||
paddingRight: el.style.paddingRight,
|
||||
});
|
||||
|
||||
return { el, elementScrollbarWidth, currentPadding };
|
||||
});
|
||||
|
||||
// ===== BATCH 2: WRITE PHASE - Apply all styles at once =====
|
||||
|
||||
// Apply body lock
|
||||
document.body.style.position = "fixed";
|
||||
document.body.style.top = `-${windowScrollY}px`;
|
||||
document.body.style.width = "100%";
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
if (scrollbarWidth > 0) {
|
||||
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||
|
||||
// Apply padding compensation to fixed elements
|
||||
this.fixedElements.forEach((el) => {
|
||||
const stored = this.originalStyles.get(el);
|
||||
if (stored) {
|
||||
el.style.paddingRight = `${stored.computedPadding + scrollbarWidth}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Lock all scrollable containers
|
||||
scrollableInfo.forEach(({ el, elementScrollbarWidth, currentPadding }) => {
|
||||
el.style.overflow = "hidden";
|
||||
|
||||
if (elementScrollbarWidth > 0) {
|
||||
el.style.paddingRight = `${currentPadding + elementScrollbarWidth}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock scrolling on all elements (optimized)
|
||||
* @param {number} delay - Delay in milliseconds before unlocking (for animations)
|
||||
*/
|
||||
unlock(delay = 0) {
|
||||
if (!this.locked) return;
|
||||
|
||||
const performUnlock = () => {
|
||||
// Restore body scroll
|
||||
const bodyStyles = this.originalStyles.get("body");
|
||||
if (bodyStyles) {
|
||||
document.body.style.position = bodyStyles.position;
|
||||
document.body.style.top = bodyStyles.top;
|
||||
document.body.style.width = bodyStyles.width;
|
||||
document.body.style.overflow = bodyStyles.overflow;
|
||||
document.body.style.paddingRight = bodyStyles.paddingRight;
|
||||
}
|
||||
|
||||
// Restore window scroll position
|
||||
const windowScrollY = this.scrollPositions.get("window") || 0;
|
||||
window.scrollTo(0, windowScrollY);
|
||||
|
||||
// Restore all scrollable containers
|
||||
this.scrollableElements.forEach((el) => {
|
||||
const originalStyles = this.originalStyles.get(el);
|
||||
if (originalStyles) {
|
||||
el.style.overflow = originalStyles.overflow;
|
||||
el.style.overflowY = originalStyles.overflowY;
|
||||
el.style.paddingRight = originalStyles.paddingRight;
|
||||
}
|
||||
|
||||
// Restore scroll position
|
||||
const scrollPosition = this.scrollPositions.get(el) || 0;
|
||||
el.scrollTop = scrollPosition;
|
||||
});
|
||||
|
||||
// Restore fixed-position elements padding
|
||||
this.fixedElements.forEach((el) => {
|
||||
const styles = this.originalStyles.get(el);
|
||||
if (styles && styles.paddingRight !== undefined) {
|
||||
el.style.paddingRight = styles.paddingRight;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear storage
|
||||
this.scrollableElements = [];
|
||||
this.fixedElements = [];
|
||||
this.scrollPositions.clear();
|
||||
this.originalStyles.clear();
|
||||
this.locked = false;
|
||||
};
|
||||
|
||||
if (delay > 0) {
|
||||
setTimeout(performUnlock, delay);
|
||||
} else {
|
||||
performUnlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if scrolling is currently locked
|
||||
*/
|
||||
isLocked() {
|
||||
return this.locked;
|
||||
}
|
||||
}
|
||||
|
||||
// Export as singleton
|
||||
window.ScrollLock = new ScrollLock();
|
||||
})();
|
||||
@@ -1,17 +1,41 @@
|
||||
use crate::components::layout::protected::Protected;
|
||||
use crate::components::torrent::table::TorrentTable;
|
||||
use crate::components::torrent::detail::TorrentDetail;
|
||||
use crate::components::auth::login::Login;
|
||||
use crate::components::auth::setup::Setup;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_router::components::{Router, Routes, Route};
|
||||
use leptos_router::hooks::use_navigate;
|
||||
use leptos_shadcn_skeleton::Skeleton;
|
||||
use leptos_shadcn_toast::SonnerProvider;
|
||||
use crate::components::ui::toast::Toaster;
|
||||
use crate::components::hooks::use_theme_mode::ThemeMode;
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
crate::components::ui::toast::provide_toaster();
|
||||
let theme_mode = ThemeMode::init();
|
||||
|
||||
// Sync theme with document
|
||||
Effect::new(move |_| {
|
||||
let is_dark = theme_mode.get();
|
||||
if let Some(doc) = document().document_element() {
|
||||
if is_dark {
|
||||
let _ = doc.class_list().add_1("dark");
|
||||
let _ = doc.set_attribute("data-theme", "dark");
|
||||
} else {
|
||||
let _ = doc.class_list().remove_1("dark");
|
||||
let _ = doc.set_attribute("data-theme", "light");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<Toaster />
|
||||
<InnerApp />
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn InnerApp() -> impl IntoView {
|
||||
crate::store::provide_torrent_store();
|
||||
let store = use_context::<crate::store::TorrentStore>();
|
||||
|
||||
@@ -22,6 +46,7 @@ pub fn App() -> impl IntoView {
|
||||
Effect::new(move |_| {
|
||||
spawn_local(async move {
|
||||
log::info!("App initialization started...");
|
||||
gloo_console::log!("APP INIT: Checking setup status...");
|
||||
|
||||
// Check if setup is needed via Server Function
|
||||
match shared::server_fns::auth::get_setup_status().await {
|
||||
@@ -54,6 +79,7 @@ pub fn App() -> impl IntoView {
|
||||
}
|
||||
|
||||
is_loading.1.set(false);
|
||||
crate::store::toast_success("VibeTorrent'e Hoşgeldiniz");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,126 +96,120 @@ pub fn App() -> impl IntoView {
|
||||
});
|
||||
|
||||
view! {
|
||||
<SonnerProvider>
|
||||
<div class="relative w-full h-screen" style="height: 100dvh;">
|
||||
<Router>
|
||||
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
|
||||
<Route path=leptos_router::path!("/login") view=move || {
|
||||
let authenticated = is_authenticated.0.get();
|
||||
let setup_needed = needs_setup.0.get();
|
||||
|
||||
Effect::new(move |_| {
|
||||
if setup_needed {
|
||||
let navigate = use_navigate();
|
||||
<div class="relative w-full h-screen" style="height: 100dvh;">
|
||||
<Router>
|
||||
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
|
||||
<Route path=leptos_router::path!("/login") view=move || {
|
||||
let authenticated = is_authenticated.0.get();
|
||||
let setup_needed = needs_setup.0.get();
|
||||
|
||||
Effect::new(move |_| {
|
||||
if setup_needed {
|
||||
let navigate = use_navigate();
|
||||
navigate("/setup", Default::default());
|
||||
} else if authenticated {
|
||||
log::info!("Already authenticated, redirecting to home");
|
||||
let navigate = use_navigate();
|
||||
navigate("/", Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
view! { <Login /> }
|
||||
} />
|
||||
<Route path=leptos_router::path!("/setup") view=move || {
|
||||
Effect::new(move |_| {
|
||||
if is_authenticated.0.get() {
|
||||
let navigate = use_navigate();
|
||||
navigate("/", Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
view! { <Setup /> }
|
||||
} />
|
||||
|
||||
<Route path=leptos_router::path!("/") view=move || {
|
||||
let navigate = use_navigate();
|
||||
Effect::new(move |_| {
|
||||
if !is_loading.0.get() {
|
||||
if needs_setup.0.get() {
|
||||
log::info!("Setup not completed, redirecting to setup");
|
||||
navigate("/setup", Default::default());
|
||||
} else if authenticated {
|
||||
log::info!("Already authenticated, redirecting to home");
|
||||
let navigate = use_navigate();
|
||||
navigate("/", Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
view! { <Login /> }
|
||||
} />
|
||||
<Route path=leptos_router::path!("/setup") view=move || {
|
||||
Effect::new(move |_| {
|
||||
if is_authenticated.0.get() {
|
||||
let navigate = use_navigate();
|
||||
navigate("/", Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
view! { <Setup /> }
|
||||
} />
|
||||
|
||||
<Route path=leptos_router::path!("/") view=move || {
|
||||
let navigate = use_navigate();
|
||||
Effect::new(move |_| {
|
||||
if !is_loading.0.get() {
|
||||
if needs_setup.0.get() {
|
||||
log::info!("Setup not completed, redirecting to setup");
|
||||
navigate("/setup", Default::default());
|
||||
} else if !is_authenticated.0.get() {
|
||||
log::info!("Not authenticated, redirecting to login");
|
||||
navigate("/login", Default::default());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<Show when=move || !is_loading.0.get() fallback=|| view! {
|
||||
<div class="flex h-screen bg-background">
|
||||
// Sidebar skeleton
|
||||
<div class="w-56 border-r border-border p-4 space-y-4">
|
||||
<Skeleton class="h-8 w-3/4" />
|
||||
<div class="space-y-2">
|
||||
<Skeleton class="h-6 w-full" />
|
||||
<Skeleton class="h-6 w-full" />
|
||||
<Skeleton class="h-6 w-4/5" />
|
||||
<Skeleton class="h-6 w-full" />
|
||||
<Skeleton class="h-6 w-3/5" />
|
||||
<Skeleton class="h-6 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
// Main content skeleton
|
||||
<div class="flex-1 flex flex-col">
|
||||
// Header skeleton
|
||||
<div class="border-b border-border p-4 flex items-center gap-4">
|
||||
<Skeleton class="h-8 w-48" />
|
||||
<Skeleton class="h-8 w-64" />
|
||||
<div class="ml-auto"><Skeleton class="h-8 w-24" /></div>
|
||||
</div>
|
||||
// Table skeleton rows
|
||||
<div class="flex-1 p-4 space-y-3">
|
||||
<Skeleton class="h-10 w-full" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
<Skeleton class="h-10 w-3/4" />
|
||||
</div>
|
||||
// Status bar skeleton
|
||||
<div class="border-t border-border p-3">
|
||||
<Skeleton class="h-5 w-96" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()>
|
||||
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
||||
<Protected>
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<TorrentTable />
|
||||
</div>
|
||||
<TorrentDetail />
|
||||
</div>
|
||||
</Protected>
|
||||
</Show>
|
||||
</Show>
|
||||
}.into_any()
|
||||
}/>
|
||||
|
||||
<Route path=leptos_router::path!("/settings") view=move || {
|
||||
Effect::new(move |_| {
|
||||
if !is_authenticated.0.get() {
|
||||
let navigate = use_navigate();
|
||||
} else if !is_authenticated.0.get() {
|
||||
log::info!("Not authenticated, redirecting to login");
|
||||
navigate("/login", Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<Show when=move || !is_loading.0.get() fallback=|| ()>
|
||||
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
||||
<Protected>
|
||||
<div class="p-4">"Settings Page (Coming Soon)"</div>
|
||||
</Protected>
|
||||
</Show>
|
||||
</Show>
|
||||
}
|
||||
}/>
|
||||
</Routes>
|
||||
</Router>
|
||||
</div>
|
||||
</SonnerProvider>
|
||||
});
|
||||
|
||||
view! {
|
||||
<Show when=move || !is_loading.0.get() fallback=|| view! {
|
||||
<div class="flex h-screen bg-background">
|
||||
// Sidebar skeleton
|
||||
<div class="w-56 border-r border-border p-4 space-y-4">
|
||||
<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>
|
||||
}.into_any()>
|
||||
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
||||
<Protected>
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<TorrentTable />
|
||||
</div>
|
||||
</div>
|
||||
</Protected>
|
||||
</Show>
|
||||
</Show>
|
||||
}.into_any()
|
||||
}/>
|
||||
|
||||
<Route path=leptos_router::path!("/settings") view=move || {
|
||||
Effect::new(move |_| {
|
||||
if !is_authenticated.0.get() {
|
||||
let navigate = use_navigate();
|
||||
navigate("/login", Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<Show when=move || !is_loading.0.get() fallback=|| ()>
|
||||
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
||||
<Protected>
|
||||
<div class="p-4">"Settings Page (Coming Soon)"</div>
|
||||
</Protected>
|
||||
</Show>
|
||||
</Show>
|
||||
}
|
||||
}/>
|
||||
</Routes>
|
||||
</Router>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_shadcn_card::{Card, CardHeader, CardContent};
|
||||
use leptos_shadcn_input::Input;
|
||||
use leptos_shadcn_button::Button;
|
||||
use leptos_shadcn_label::Label;
|
||||
use leptos_shadcn_alert::{Alert, AlertDescription, AlertVariant};
|
||||
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
||||
use crate::components::ui::input::{Input, InputType};
|
||||
|
||||
use crate::components::ui::button::Button;
|
||||
|
||||
#[component]
|
||||
pub fn Login() -> impl IntoView {
|
||||
let username = signal(String::new());
|
||||
let password = signal(String::new());
|
||||
let username = RwSignal::new(String::new());
|
||||
let password = RwSignal::new(String::new());
|
||||
let error = signal(Option::<String>::None);
|
||||
let loading = signal(false);
|
||||
|
||||
@@ -18,8 +17,8 @@ pub fn Login() -> impl IntoView {
|
||||
loading.1.set(true);
|
||||
error.1.set(None);
|
||||
|
||||
let user = username.0.get();
|
||||
let pass = password.0.get();
|
||||
let user = username.get();
|
||||
let pass = password.get();
|
||||
|
||||
spawn_local(async move {
|
||||
match shared::server_fns::auth::login(user, pass).await {
|
||||
@@ -52,40 +51,37 @@ pub fn Login() -> impl IntoView {
|
||||
<CardContent class="pt-4">
|
||||
<form on:submit=handle_login class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>"Kullanıcı Adı"</Label>
|
||||
<label class="text-sm font-medium leading-none">"Kullanıcı Adı"</label>
|
||||
<Input
|
||||
input_type="text"
|
||||
r#type=InputType::Text
|
||||
placeholder="Kullanıcı adınız"
|
||||
value=MaybeProp::derive(move || Some(username.0.get()))
|
||||
on_change=Callback::new(move |val: String| username.1.set(val))
|
||||
disabled=Signal::derive(move || loading.0.get())
|
||||
bind_value=username
|
||||
disabled=loading.0.get()
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>"Şifre"</Label>
|
||||
<label class="text-sm font-medium leading-none">"Şifre"</label>
|
||||
<Input
|
||||
input_type="password"
|
||||
r#type=InputType::Password
|
||||
placeholder="******"
|
||||
value=MaybeProp::derive(move || Some(password.0.get()))
|
||||
on_change=Callback::new(move |val: String| password.1.set(val))
|
||||
disabled=Signal::derive(move || loading.0.get())
|
||||
bind_value=password
|
||||
disabled=loading.0.get()
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when=move || error.0.get().is_some()>
|
||||
<Alert variant=AlertVariant::Destructive>
|
||||
<AlertDescription>
|
||||
{move || error.0.get().unwrap_or_default()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{move || error.0.get().unwrap_or_default()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="pt-2">
|
||||
<Button
|
||||
class="w-full"
|
||||
disabled=Signal::derive(move || loading.0.get())
|
||||
attr:r#type="submit"
|
||||
attr:disabled=move || loading.0.get()
|
||||
>
|
||||
<Show when=move || loading.0.get() fallback=|| "Giriş Yap">
|
||||
<Show when=move || loading.0.get() fallback=|| view! { "Giriş Yap" }.into_any()>
|
||||
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
||||
"Giriş Yapılıyor..."
|
||||
</Show>
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_shadcn_card::{Card, CardHeader, CardContent};
|
||||
use leptos_shadcn_input::Input;
|
||||
use leptos_shadcn_button::Button;
|
||||
use leptos_shadcn_label::Label;
|
||||
use leptos_shadcn_alert::{Alert, AlertDescription, AlertVariant};
|
||||
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
||||
use crate::components::ui::input::{Input, InputType};
|
||||
|
||||
use crate::components::ui::button::Button;
|
||||
|
||||
#[component]
|
||||
pub fn Setup() -> impl IntoView {
|
||||
let username = signal(String::new());
|
||||
let password = signal(String::new());
|
||||
let confirm_password = signal(String::new());
|
||||
let username = RwSignal::new(String::new());
|
||||
let password = RwSignal::new(String::new());
|
||||
let confirm_password = RwSignal::new(String::new());
|
||||
let error = signal(Option::<String>::None);
|
||||
let loading = signal(false);
|
||||
|
||||
let handle_setup = move |ev: web_sys::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
|
||||
let pass = password.0.get();
|
||||
let confirm = confirm_password.0.get();
|
||||
let pass = password.get();
|
||||
let confirm = confirm_password.get();
|
||||
|
||||
if pass != confirm {
|
||||
error.1.set(Some("Şifreler eşleşmiyor".to_string()));
|
||||
@@ -33,7 +32,7 @@ pub fn Setup() -> impl IntoView {
|
||||
loading.1.set(true);
|
||||
error.1.set(None);
|
||||
|
||||
let user = username.0.get();
|
||||
let user = username.get();
|
||||
|
||||
spawn_local(async move {
|
||||
match shared::server_fns::auth::setup(user, pass).await {
|
||||
@@ -67,50 +66,46 @@ pub fn Setup() -> impl IntoView {
|
||||
<CardContent class="pt-4">
|
||||
<form on:submit=handle_setup class="space-y-4">
|
||||
<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_type="text"
|
||||
r#type=InputType::Text
|
||||
placeholder="admin"
|
||||
value=MaybeProp::derive(move || Some(username.0.get()))
|
||||
on_change=Callback::new(move |val: String| username.1.set(val))
|
||||
disabled=Signal::derive(move || loading.0.get())
|
||||
bind_value=username
|
||||
disabled=loading.0.get()
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>"Şifre"</Label>
|
||||
<label class="text-sm font-medium leading-none">"Şifre"</label>
|
||||
<Input
|
||||
input_type="password"
|
||||
r#type=InputType::Password
|
||||
placeholder="******"
|
||||
value=MaybeProp::derive(move || Some(password.0.get()))
|
||||
on_change=Callback::new(move |val: String| password.1.set(val))
|
||||
disabled=Signal::derive(move || loading.0.get())
|
||||
bind_value=password
|
||||
disabled=loading.0.get()
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>"Şifre Onay"</Label>
|
||||
<label class="text-sm font-medium leading-none">"Şifre Onay"</label>
|
||||
<Input
|
||||
input_type="password"
|
||||
r#type=InputType::Password
|
||||
placeholder="******"
|
||||
value=MaybeProp::derive(move || Some(confirm_password.0.get()))
|
||||
on_change=Callback::new(move |val: String| confirm_password.1.set(val))
|
||||
disabled=Signal::derive(move || loading.0.get())
|
||||
bind_value=confirm_password
|
||||
disabled=loading.0.get()
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when=move || error.0.get().is_some() fallback=|| ()>
|
||||
<Alert variant=AlertVariant::Destructive>
|
||||
<AlertDescription>
|
||||
<span>{move || error.0.get().unwrap_or_default()}</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<span>{move || error.0.get().unwrap_or_default()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="pt-2">
|
||||
<Button
|
||||
class="w-full"
|
||||
disabled=Signal::derive(move || loading.0.get())
|
||||
attr:r#type="submit"
|
||||
attr:disabled=move || loading.0.get()
|
||||
>
|
||||
<Show when=move || loading.0.get() fallback=|| "Kurulumu Tamamla">
|
||||
<Show when=move || loading.0.get() fallback=|| view! { "Kurulumu Tamamla" }.into_any()>
|
||||
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
||||
"Kuruluyor..."
|
||||
</Show>
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
use leptos::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
// ── Kendi reaktif Context Menu implementasyonumuz ──
|
||||
// leptos-shadcn-context-menu v0.8.1'de ContextMenuContent'te
|
||||
// `if open.get()` statik kontrolü reaktif değil. Aşağıda
|
||||
// `Show` bileşeni ile düzgün reaktif versiyon yer alıyor.
|
||||
use crate::components::ui::context_menu::*;
|
||||
|
||||
#[component]
|
||||
pub fn TorrentContextMenu(
|
||||
@@ -15,144 +8,71 @@ pub fn TorrentContextMenu(
|
||||
on_action: Callback<(String, String)>,
|
||||
) -> impl IntoView {
|
||||
let hash = StoredValue::new(torrent_hash);
|
||||
let on_action = StoredValue::new(on_action);
|
||||
|
||||
let open = RwSignal::new(false);
|
||||
let position = RwSignal::new((0i32, 0i32));
|
||||
|
||||
// Sağ tıklama handler
|
||||
let on_contextmenu = move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
position.set((e.client_x(), e.client_y()));
|
||||
open.set(true);
|
||||
};
|
||||
|
||||
// Menü dışına tıklandığında kapanma
|
||||
Effect::new(move |_| {
|
||||
if open.get() {
|
||||
let cb = Closure::wrap(Box::new(move |_: MouseEvent| {
|
||||
open.set(false);
|
||||
}) as Box<dyn Fn(MouseEvent)>);
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let document = window.document().unwrap();
|
||||
let _ = document.add_event_listener_with_callback(
|
||||
"click",
|
||||
cb.as_ref().unchecked_ref(),
|
||||
);
|
||||
|
||||
// Cleanup: tek sefer dinleyici — click yakalandığında otomatik kapanıp listener kalıyor
|
||||
// ama open=false olduğunda effect tekrar çalışmaz, böylece sorun yok.
|
||||
cb.forget();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
let menu_action = move |action: &'static str| {
|
||||
open.set(false);
|
||||
on_action.get_value().run((action.to_string(), hash.get_value()));
|
||||
on_action.run((action.to_string(), hash.get_value()));
|
||||
};
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="w-full"
|
||||
on:contextmenu=on_contextmenu
|
||||
>
|
||||
{children()}
|
||||
</div>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
{children()}
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent class="w-56">
|
||||
<ContextMenuAction
|
||||
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
|
||||
on:click=move |_| menu_action("start")
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||
</svg>
|
||||
"Start"
|
||||
</ContextMenuAction>
|
||||
|
||||
<Show when=move || open.get()>
|
||||
{
|
||||
let (x, y) = position.get();
|
||||
// Menü yaklaşık boyutları
|
||||
let menu_width = 200;
|
||||
let menu_height = 220;
|
||||
let window = web_sys::window().unwrap();
|
||||
let vw = window.inner_width().unwrap().as_f64().unwrap() as i32;
|
||||
let vh = window.inner_height().unwrap().as_f64().unwrap() as i32;
|
||||
// Sağa taşarsa sola aç, alta taşarsa yukarı aç
|
||||
let final_x = if x + menu_width > vw { x - menu_width } else { x };
|
||||
let final_y = if y + menu_height > vh { y - menu_height } else { y };
|
||||
let final_x = final_x.max(0);
|
||||
let final_y = final_y.max(0);
|
||||
view! {
|
||||
<div
|
||||
class="fixed inset-0 z-[99]"
|
||||
on:click=move |e: MouseEvent| {
|
||||
e.stop_propagation();
|
||||
open.set(false);
|
||||
}
|
||||
on:contextmenu=move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
open.set(false);
|
||||
}
|
||||
/>
|
||||
<div
|
||||
class="fixed z-[100] min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
style=format!("left: {}px; top: {}px;", final_x, final_y)
|
||||
on:click=move |e: MouseEvent| e.stop_propagation()
|
||||
>
|
||||
// Start
|
||||
<div
|
||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
on:click=move |_| menu_action("start")
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||
</svg>
|
||||
"Start"
|
||||
</div>
|
||||
<ContextMenuAction
|
||||
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
|
||||
on:click=move |_| menu_action("stop")
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||
</svg>
|
||||
"Stop"
|
||||
</ContextMenuAction>
|
||||
|
||||
// Stop
|
||||
<div
|
||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
on:click=move |_| menu_action("stop")
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||
</svg>
|
||||
"Stop"
|
||||
</div>
|
||||
<ContextMenuAction
|
||||
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
|
||||
on:click=move |_| menu_action("recheck")
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
"Recheck"
|
||||
</ContextMenuAction>
|
||||
|
||||
// Recheck
|
||||
<div
|
||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
on:click=move |_| menu_action("recheck")
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
"Recheck"
|
||||
</div>
|
||||
<div class="-mx-1 my-1 h-px bg-border" />
|
||||
|
||||
// Separator
|
||||
<div class="-mx-1 my-1 h-px bg-border" />
|
||||
<ContextMenuAction
|
||||
class="px-2 py-1.5 text-destructive hover:bg-destructive/10 hover:text-destructive rounded-sm"
|
||||
on:click=move |_| menu_action("delete")
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.164h-2.34c-1.18 0-2.09.984-2.09 2.164v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
"Remove"
|
||||
</ContextMenuAction>
|
||||
|
||||
// Remove
|
||||
<div
|
||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
on:click=move |_| menu_action("delete")
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.164h-2.34c-1.18 0-2.09.984-2.09 2.164v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
"Remove"
|
||||
</div>
|
||||
|
||||
// Remove with Data
|
||||
<div
|
||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
on:click=move |_| menu_action("delete_with_data")
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
"Remove with Data"
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</Show>
|
||||
<ContextMenuHoldAction
|
||||
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
on_hold_complete=move |_| menu_action("delete_with_data")
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
"Remove with Data"
|
||||
<span class="ml-auto text-[10px] opacity-50">"Hold"</span>
|
||||
</ContextMenuHoldAction>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
}
|
||||
}
|
||||
3
frontend/src/components/hooks/mod.rs
Normal file
3
frontend/src/components/hooks/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod use_random;
|
||||
pub mod use_theme_mode;
|
||||
pub mod use_can_scroll_vertical;
|
||||
25
frontend/src/components/hooks/use_can_scroll_vertical.rs
Normal file
25
frontend/src/components/hooks/use_can_scroll_vertical.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
/// Hook to determine if an element can scroll vertically.
|
||||
///
|
||||
/// Returns (on_scroll_callback, can_scroll_up_signal, can_scroll_down_signal)
|
||||
pub fn use_can_scroll_vertical() -> (Callback<web_sys::Event>, ReadSignal<bool>, ReadSignal<bool>) {
|
||||
let can_scroll_up = RwSignal::new(false);
|
||||
let can_scroll_down = RwSignal::new(false);
|
||||
|
||||
let on_scroll = Callback::new(move |ev: web_sys::Event| {
|
||||
if let Some(target) = ev.target() {
|
||||
if let Some(el) = target.dyn_ref::<web_sys::HtmlElement>() {
|
||||
let scroll_top = el.scroll_top();
|
||||
let scroll_height = el.scroll_height();
|
||||
let client_height = el.client_height();
|
||||
|
||||
can_scroll_up.set(scroll_top > 0);
|
||||
can_scroll_down.set(scroll_top + client_height < scroll_height - 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(on_scroll, can_scroll_up.read_only(), can_scroll_down.read_only())
|
||||
}
|
||||
31
frontend/src/components/hooks/use_random.rs
Normal file
31
frontend/src/components/hooks/use_random.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
const PREFIX: &str = "rust_ui"; // Must NOT contain "/" or "-"
|
||||
|
||||
pub fn use_random_id() -> String {
|
||||
format!("_{PREFIX}_{}", generate_hash())
|
||||
}
|
||||
|
||||
pub fn use_random_id_for(element: &str) -> String {
|
||||
format!("{}_{PREFIX}_{}", element, generate_hash())
|
||||
}
|
||||
|
||||
pub fn use_random_transition_name() -> String {
|
||||
let random_id = use_random_id();
|
||||
format!("view-transition-name: {random_id}")
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(1);
|
||||
|
||||
fn generate_hash() -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let counter = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
counter.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
108
frontend/src/components/hooks/use_theme_mode.rs
Normal file
108
frontend/src/components/hooks/use_theme_mode.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use leptos::prelude::*;
|
||||
use web_sys::Storage;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ThemeMode {
|
||||
state: RwSignal<bool>,
|
||||
}
|
||||
|
||||
const LOCALSTORAGE_KEY: &str = "darkmode";
|
||||
|
||||
/// Hook to access the dark mode context
|
||||
///
|
||||
/// Returns the ThemeMode instance from context for easy access
|
||||
pub fn use_theme_mode() -> ThemeMode {
|
||||
expect_context::<ThemeMode>()
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
impl ThemeMode {
|
||||
#[must_use]
|
||||
/// Initializes a new ThemeMode instance.
|
||||
pub fn init() -> Self {
|
||||
let theme_mode = Self { state: RwSignal::new(false) };
|
||||
|
||||
provide_context(theme_mode);
|
||||
|
||||
// Use Effect to handle browser-only initialization
|
||||
Effect::new(move |_| {
|
||||
let initial = Self::get_storage_state().unwrap_or(Self::prefers_dark_mode());
|
||||
theme_mode.state.set(initial);
|
||||
});
|
||||
|
||||
theme_mode
|
||||
}
|
||||
|
||||
pub fn toggle(&self) {
|
||||
self.state.update(|state| {
|
||||
*state = !*state;
|
||||
Self::set_storage_state(*state);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_dark(&self) {
|
||||
self.set(true);
|
||||
}
|
||||
|
||||
pub fn set_light(&self) {
|
||||
self.set(false);
|
||||
}
|
||||
|
||||
/// - `dark`: Set to `true` for dark mode, and `false` for light mode.
|
||||
pub fn set(&self, dark: bool) {
|
||||
self.state.set(dark);
|
||||
Self::set_storage_state(dark);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self) -> bool {
|
||||
self.state.get()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_dark(&self) -> bool {
|
||||
self.state.get()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_light(&self) -> bool {
|
||||
!self.state.get()
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
/// Retrieves the local storage object, if available.
|
||||
fn get_storage() -> Option<Storage> {
|
||||
window().local_storage().ok().flatten()
|
||||
}
|
||||
|
||||
/// Retrieves the dark mode state from local storage, if available.
|
||||
fn get_storage_state() -> Option<bool> {
|
||||
Self::get_storage()
|
||||
.and_then(|storage| storage.get(LOCALSTORAGE_KEY).ok())
|
||||
.flatten()
|
||||
.and_then(|entry| entry.parse::<bool>().ok())
|
||||
}
|
||||
|
||||
/// Checks whether the user's system prefers dark mode based on media queries.
|
||||
fn prefers_dark_mode() -> bool {
|
||||
window()
|
||||
.match_media("(prefers-color-scheme: dark)")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|media| media.matches())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Stores the dark mode state in local storage.
|
||||
fn set_storage_state(state: bool) {
|
||||
if let Some(storage) = Self::get_storage() {
|
||||
storage.set(LOCALSTORAGE_KEY, state.to_string().as_str()).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_shadcn_button::{Button, ButtonVariant, ButtonSize};
|
||||
use leptos_shadcn_avatar::{Avatar, AvatarFallback};
|
||||
use leptos_shadcn_separator::Separator;
|
||||
|
||||
use leptos_use::storage::use_local_storage;
|
||||
use ::codee::string::FromToStringCodec;
|
||||
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
|
||||
|
||||
#[component]
|
||||
pub fn Sidebar() -> impl IntoView {
|
||||
@@ -70,37 +65,6 @@ pub fn Sidebar() -> impl IntoView {
|
||||
username().chars().next().unwrap_or('?').to_uppercase().to_string()
|
||||
};
|
||||
|
||||
// --- THEME LOGIC START ---
|
||||
let (current_theme, set_current_theme, _) = use_local_storage::<String, FromToStringCodec>("vibetorrent_theme");
|
||||
|
||||
// Initialize with default if empty
|
||||
let current_theme_val = current_theme.get();
|
||||
if current_theme_val.is_empty() {
|
||||
set_current_theme.set("dark".to_string());
|
||||
}
|
||||
|
||||
// Automatically sync theme to document attribute
|
||||
Effect::new(move |_| {
|
||||
let theme = current_theme.get().to_lowercase();
|
||||
if let Some(doc) = document().document_element() {
|
||||
let _ = doc.set_attribute("data-theme", &theme);
|
||||
// Also set class for Shadcn dark mode support
|
||||
if theme == "dark" || theme == "dracula" || theme == "dim" || theme == "abyss" || theme == "sunset" || theme == "cyberpunk" || theme == "nord" || theme == "business" || theme == "night" || theme == "black" || theme == "luxury" || theme == "coffee" || theme == "forest" || theme == "halloween" || theme == "synthwave" {
|
||||
let _ = doc.class_list().add_1("dark");
|
||||
} else {
|
||||
let _ = doc.class_list().remove_1("dark");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
let toggle_theme = move |_| {
|
||||
let new_theme = if current_theme.get() == "dark" { "light" } else { "dark" };
|
||||
set_current_theme.set(new_theme.to_string());
|
||||
};
|
||||
// --- THEME LOGIC END ---
|
||||
|
||||
view! {
|
||||
<div class="w-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">
|
||||
@@ -110,132 +74,83 @@ pub fn Sidebar() -> impl IntoView {
|
||||
<div class="space-y-1">
|
||||
<h4 class="mb-1 rounded-md px-2 py-1 text-sm font-semibold text-muted-foreground">"Filters"</h4>
|
||||
|
||||
<Button
|
||||
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::All) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
||||
size=ButtonSize::Sm
|
||||
class="w-full justify-start gap-2"
|
||||
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::All))
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
"All"
|
||||
<span class="ml-auto text-xs font-mono opacity-70">{total_count}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Downloading) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
||||
size=ButtonSize::Sm
|
||||
class="w-full justify-start gap-2"
|
||||
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Downloading))
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
"Downloading"
|
||||
<span class="ml-auto text-xs font-mono opacity-70">{downloading_count}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Seeding) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
||||
size=ButtonSize::Sm
|
||||
class="w-full justify-start gap-2"
|
||||
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Seeding))
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
"Seeding"
|
||||
<span class="ml-auto text-xs font-mono opacity-70">{seeding_count}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Completed) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
||||
size=ButtonSize::Sm
|
||||
class="w-full justify-start gap-2"
|
||||
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Completed))
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
"Completed"
|
||||
<span class="ml-auto text-xs font-mono opacity-70">{completed_count}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Paused) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
||||
size=ButtonSize::Sm
|
||||
class="w-full justify-start gap-2"
|
||||
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Paused))
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||
</svg>
|
||||
"Paused"
|
||||
<span class="ml-auto text-xs font-mono opacity-70">{paused_count}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Inactive) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
|
||||
size=ButtonSize::Sm
|
||||
class="w-full justify-start gap-2"
|
||||
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Inactive))
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
"Inactive"
|
||||
<span class="ml-auto text-xs font-mono opacity-70">{inactive_count}</span>
|
||||
</Button>
|
||||
<SidebarButton
|
||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::All))
|
||||
on_click=move |_| set_filter(crate::store::FilterStatus::All)
|
||||
icon="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
label="All"
|
||||
count=Signal::derive(total_count)
|
||||
/>
|
||||
<SidebarButton
|
||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Downloading))
|
||||
on_click=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.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
label="Downloading"
|
||||
count=Signal::derive(downloading_count)
|
||||
/>
|
||||
<SidebarButton
|
||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Seeding))
|
||||
on_click=move |_| set_filter(crate::store::FilterStatus::Seeding)
|
||||
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"
|
||||
count=Signal::derive(seeding_count)
|
||||
/>
|
||||
<SidebarButton
|
||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Completed))
|
||||
on_click=move |_| set_filter(crate::store::FilterStatus::Completed)
|
||||
icon="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
label="Completed"
|
||||
count=Signal::derive(completed_count)
|
||||
/>
|
||||
<SidebarButton
|
||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Paused))
|
||||
on_click=move |_| set_filter(crate::store::FilterStatus::Paused)
|
||||
icon="M15.75 5.25v13.5m-7.5-13.5v13.5"
|
||||
label="Paused"
|
||||
count=Signal::derive(paused_count)
|
||||
/>
|
||||
<SidebarButton
|
||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Inactive))
|
||||
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"
|
||||
label="Inactive"
|
||||
count=Signal::derive(inactive_count)
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
// Separator
|
||||
<div class="border-t border-border" />
|
||||
|
||||
<div class="p-4 bg-card" style="padding-bottom: calc(1rem + env(safe-area-inset-bottom));">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarFallback class="bg-primary text-primary-foreground text-xs font-medium">
|
||||
{first_letter}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
// Avatar
|
||||
<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}
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="font-medium text-sm truncate text-foreground">{username}</div>
|
||||
<div class="text-[10px] text-muted-foreground truncate">"Online"</div>
|
||||
</div>
|
||||
|
||||
// --- THEME BUTTON ---
|
||||
// Theme toggle button
|
||||
<div 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">
|
||||
<crate::components::ui::theme_toggle::ThemeToggle />
|
||||
</div>
|
||||
// Logout button
|
||||
<Button
|
||||
variant=ButtonVariant::Ghost
|
||||
size=ButtonSize::Icon
|
||||
class="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
on_click=Callback::new(toggle_theme)
|
||||
>
|
||||
// Sun icon for dark mode (to switch to light), Moon for light (to switch to dark)
|
||||
// Actually show current state or action? Usually action.
|
||||
// If dark, show Sun. If light, show Moon.
|
||||
<Show when=move || current_theme.get() == "dark" fallback=|| view! {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</Show>
|
||||
</Button>
|
||||
<Button
|
||||
variant=ButtonVariant::Ghost
|
||||
size=ButtonSize::Icon
|
||||
class="text-destructive h-8 w-8"
|
||||
on_click=Callback::new(move |()| {
|
||||
class="text-destructive hover:bg-destructive/10"
|
||||
attr:disabled=move || false
|
||||
on:click=move |_| {
|
||||
spawn_local(async move {
|
||||
if shared::server_fns::auth::logout().await.is_ok() {
|
||||
let window = web_sys::window().expect("window should exist");
|
||||
let _ = window.location().set_href("/login");
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
@@ -246,3 +161,28 @@ pub fn Sidebar() -> impl IntoView {
|
||||
</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 {
|
||||
let variant = move || if active.get() { ButtonVariant::Secondary } else { ButtonVariant::Ghost };
|
||||
|
||||
view! {
|
||||
<Button
|
||||
variant=Signal::derive(variant)
|
||||
class="justify-start gap-2 w-full h-8 px-3"
|
||||
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,12 +1,10 @@
|
||||
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::ui::button::{Button};
|
||||
|
||||
#[component]
|
||||
pub fn Toolbar() -> impl IntoView {
|
||||
let show_add_modal = signal(false);
|
||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||
let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
|
||||
|
||||
view! {
|
||||
@@ -14,18 +12,16 @@ pub fn Toolbar() -> impl IntoView {
|
||||
// Sol kısım: Menü butonu + Add Torrent
|
||||
<div class="flex items-center gap-3">
|
||||
// Mobile Menu Trigger
|
||||
<Button
|
||||
variant=ButtonVariant::Ghost
|
||||
size=ButtonSize::Icon
|
||||
class="lg:hidden"
|
||||
on_click=Callback::new(move |()| is_mobile_menu_open.update(|v| *v = !*v))
|
||||
<button
|
||||
class="inline-flex items-center justify-center size-9 rounded-md hover:bg-accent hover:text-accent-foreground lg:hidden"
|
||||
on:click=move |_| is_mobile_menu_open.update(|v| *v = !*v)
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
class="gap-2 shadow"
|
||||
on_click=Callback::new(move |()| show_add_modal.1.set(true))
|
||||
on:click=move |_| show_add_modal.1.set(true)
|
||||
class="gap-2"
|
||||
>
|
||||
<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" />
|
||||
@@ -35,22 +31,8 @@ pub fn Toolbar() -> impl IntoView {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
// Sağ kısım: Search kutusu
|
||||
// Sağ kısım boşaltıldı (arama kutusu kaldırıldı)
|
||||
<div class="flex flex-1 items-center justify-end gap-2">
|
||||
<div class="hidden md:flex items-center gap-2 w-full max-w-xs">
|
||||
<div class="relative flex-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
<Input
|
||||
input_type="search"
|
||||
placeholder="Search..."
|
||||
value=MaybeProp::derive(move || Some(store.search_query.get()))
|
||||
on_change=Callback::new(move |val: String| store.search_query.set(val))
|
||||
class="pl-8 h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when=move || show_add_modal.0.get()>
|
||||
@@ -58,4 +40,4 @@ pub fn Toolbar() -> impl IntoView {
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
pub mod hooks;
|
||||
pub mod context_menu;
|
||||
pub mod layout;
|
||||
pub mod torrent;
|
||||
pub mod auth;
|
||||
// pub mod toast; (Removed)
|
||||
pub mod ui;
|
||||
|
||||
111
frontend/src/components/toast.rs
Normal file
111
frontend/src/components/toast.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
use shared::NotificationLevel;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Toast {
|
||||
pub id: String,
|
||||
pub message: String,
|
||||
pub level: NotificationLevel,
|
||||
pub visible: RwSignal<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ToastContext {
|
||||
pub toasts: RwSignal<HashMap<String, Toast>>,
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn provide_toast_context() {
|
||||
let toasts = RwSignal::new(HashMap::new());
|
||||
provide_context(ToastContext { toasts });
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Toaster() -> impl IntoView {
|
||||
let context = expect_context::<ToastContext>();
|
||||
|
||||
view! {
|
||||
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-sm pointer-events-none">
|
||||
{move || {
|
||||
context.toasts.get().into_values().map(|toast| {
|
||||
view! { <ToastItem toast=toast /> }
|
||||
}).collect::<Vec<_>>()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ToastItem(toast: Toast) -> impl IntoView {
|
||||
let (visible, set_visible) = (toast.visible, toast.visible.write_only());
|
||||
|
||||
let base_classes = "pointer-events-auto relative w-full rounded-lg border p-4 shadow-lg transition-all duration-300 ease-in-out";
|
||||
let color_classes = match toast.level {
|
||||
NotificationLevel::Success => "bg-green-50 text-green-900 border-green-200 dark:bg-green-900 dark:text-green-100 dark:border-green-800",
|
||||
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",
|
||||
NotificationLevel::Info => "bg-blue-50 text-blue-900 border-blue-200 dark:bg-blue-900 dark:text-blue-100 dark:border-blue-800",
|
||||
};
|
||||
|
||||
view! {
|
||||
<div
|
||||
class=move || format!("{} {} {}",
|
||||
base_classes,
|
||||
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>
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_shadcn_input::Input;
|
||||
use leptos_shadcn_button::{Button, ButtonVariant};
|
||||
use leptos_shadcn_alert::{Alert, AlertDescription, AlertVariant};
|
||||
use crate::components::ui::input::{Input, InputType};
|
||||
use crate::store::TorrentStore;
|
||||
use crate::api;
|
||||
|
||||
use crate::components::ui::button::{Button, ButtonVariant};
|
||||
|
||||
#[component]
|
||||
pub fn AddTorrentDialog(
|
||||
on_close: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
let store = use_context::<TorrentStore>().expect("TorrentStore not provided");
|
||||
let _store = use_context::<TorrentStore>().expect("TorrentStore not provided");
|
||||
|
||||
let uri = signal(String::new());
|
||||
let uri = RwSignal::new(String::new());
|
||||
let is_loading = signal(false);
|
||||
let error_msg = signal(Option::<String>::None);
|
||||
|
||||
let handle_submit = move |ev: web_sys::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
let uri_val = uri.0.get();
|
||||
let uri_val = uri.get();
|
||||
|
||||
if uri_val.is_empty() {
|
||||
error_msg.1.set(Some("Please enter a Magnet URI or URL".to_string()));
|
||||
@@ -69,29 +69,30 @@ pub fn AddTorrentDialog(
|
||||
|
||||
<form on:submit=handle_submit class="space-y-4">
|
||||
<Input
|
||||
input_type="text"
|
||||
r#type=InputType::Text
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
value=MaybeProp::derive(move || Some(uri.0.get()))
|
||||
on_change=Callback::new(move |val: String| uri.1.set(val))
|
||||
disabled=Signal::derive(move || is_loading.0.get())
|
||||
bind_value=uri
|
||||
disabled=is_loading.0.get()
|
||||
/>
|
||||
|
||||
{move || error_msg.0.get().map(|msg| view! {
|
||||
<Alert variant=AlertVariant::Destructive>
|
||||
<AlertDescription>{msg}</AlertDescription>
|
||||
</Alert>
|
||||
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{msg}
|
||||
</div>
|
||||
})}
|
||||
|
||||
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
||||
<Button
|
||||
variant=ButtonVariant::Ghost
|
||||
on_click=Callback::new(move |()| {
|
||||
on_close.run(());
|
||||
})
|
||||
attr:r#type="button"
|
||||
on:click=move |_| on_close.run(())
|
||||
>
|
||||
"Cancel"
|
||||
</Button>
|
||||
<Button disabled=Signal::derive(move || is_loading.0.get())>
|
||||
<Button
|
||||
attr:r#type="submit"
|
||||
attr: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>
|
||||
@@ -105,8 +106,9 @@ pub fn AddTorrentDialog(
|
||||
</form>
|
||||
|
||||
// Close button (X)
|
||||
<button
|
||||
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none"
|
||||
<Button
|
||||
variant=ButtonVariant::Ghost
|
||||
class="absolute right-2 top-2 size-8 p-0 opacity-70 hover:opacity-100"
|
||||
on:click=move |_| on_close.run(())
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
|
||||
@@ -114,7 +116,7 @@ pub fn AddTorrentDialog(
|
||||
<path d="m6 6 12 12"></path>
|
||||
</svg>
|
||||
<span class="sr-only">"Close"</span>
|
||||
</button>
|
||||
</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 add_torrent;
|
||||
pub mod detail;
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use std::collections::HashSet;
|
||||
use icons::{ArrowUpDown, Inbox, Settings2, Play, Square, Trash2, Ellipsis};
|
||||
use crate::store::{get_action_messages, show_toast};
|
||||
use crate::api;
|
||||
use shared::NotificationLevel;
|
||||
use crate::components::context_menu::TorrentContextMenu;
|
||||
use leptos_shadcn_card::{Card, CardHeader, CardTitle, CardContent};
|
||||
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent as CardBody};
|
||||
use crate::components::ui::data_table::*;
|
||||
use crate::components::ui::checkbox::Checkbox;
|
||||
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
|
||||
use crate::components::ui::empty::*;
|
||||
use crate::components::ui::input::Input;
|
||||
use crate::components::ui::multi_select::*;
|
||||
use crate::components::ui::dropdown_menu::*;
|
||||
use crate::components::ui::alert_dialog::*;
|
||||
|
||||
const ALL_COLUMNS: [(&str, &str); 8] = [
|
||||
("Name", "Name"),
|
||||
("Size", "Size"),
|
||||
("Progress", "Progress"),
|
||||
("Status", "Status"),
|
||||
("DownSpeed", "DL Speed"),
|
||||
("UpSpeed", "UP Speed"),
|
||||
("ETA", "ETA"),
|
||||
("AddedDate", "Date"),
|
||||
];
|
||||
|
||||
fn format_bytes(bytes: i64) -> String {
|
||||
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
@@ -49,8 +70,16 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||
let sort_col = signal(SortColumn::AddedDate);
|
||||
let sort_dir = signal(SortDirection::Descending);
|
||||
|
||||
let selected_hashes = RwSignal::new(HashSet::<String>::new());
|
||||
|
||||
let visible_columns = RwSignal::new(HashSet::from([
|
||||
"Name".to_string(), "Size".to_string(), "Progress".to_string(),
|
||||
"Status".to_string(), "DownSpeed".to_string(), "UpSpeed".to_string(),
|
||||
"ETA".to_string(), "AddedDate".to_string()
|
||||
]));
|
||||
|
||||
let filtered_hashes = Memo::new(move |_| {
|
||||
let sorted_hashes_data = Memo::new(move |_| {
|
||||
let torrents_map = store.torrents.get();
|
||||
let filter = store.filter.get();
|
||||
let search = store.search_query.get();
|
||||
@@ -89,7 +118,30 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
};
|
||||
if dir == SortDirection::Descending { cmp.reverse() } else { cmp }
|
||||
});
|
||||
torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
|
||||
torrents
|
||||
});
|
||||
|
||||
let filtered_hashes = Memo::new(move |_| {
|
||||
sorted_hashes_data.get().into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
|
||||
});
|
||||
|
||||
let selected_count = Signal::derive(move || {
|
||||
let current_hashes: HashSet<String> = filtered_hashes.get().into_iter().collect();
|
||||
selected_hashes.with(|selected| {
|
||||
selected.iter().filter(|h| current_hashes.contains(*h)).count()
|
||||
})
|
||||
});
|
||||
|
||||
let has_selection = Signal::derive(move || selected_count.get() > 0);
|
||||
|
||||
let handle_select_all = Callback::new(move |checked: bool| {
|
||||
selected_hashes.update(|selected| {
|
||||
let hashes = filtered_hashes.get_untracked();
|
||||
for h in hashes {
|
||||
if checked { selected.insert(h); }
|
||||
else { selected.remove(&h); }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let handle_sort = move |col: SortColumn| {
|
||||
@@ -103,13 +155,35 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
}
|
||||
};
|
||||
|
||||
let sort_arrow = move |col: SortColumn| {
|
||||
if sort_col.0.get() == col {
|
||||
match sort_dir.0.get() {
|
||||
SortDirection::Ascending => view! { <span class="ml-1 text-xs">"▲"</span> }.into_any(),
|
||||
SortDirection::Descending => view! { <span class="ml-1 text-xs">"▼"</span> }.into_any(),
|
||||
let sort_icon = move |col: SortColumn| {
|
||||
let is_active = sort_col.0.get() == col;
|
||||
let class = if is_active { "size-3 text-primary" } else { "size-3 opacity-30 group-hover:opacity-100 transition-opacity" };
|
||||
view! { <ArrowUpDown class=class.to_string() /> }.into_any()
|
||||
};
|
||||
|
||||
let bulk_action = move |action: &'static str| {
|
||||
let hashes: Vec<String> = selected_hashes.get().into_iter().collect();
|
||||
if hashes.is_empty() { return; }
|
||||
|
||||
spawn_local(async move {
|
||||
let mut success = true;
|
||||
for hash in hashes {
|
||||
let res = match action {
|
||||
"start" => api::torrent::start(&hash).await,
|
||||
"stop" => api::torrent::stop(&hash).await,
|
||||
"delete" => api::torrent::delete(&hash).await,
|
||||
"delete_with_data" => api::torrent::delete_with_data(&hash).await,
|
||||
_ => Ok(()),
|
||||
};
|
||||
if res.is_err() { success = false; }
|
||||
}
|
||||
} else { view! { <span class="ml-1 text-xs opacity-0 group-hover:opacity-50">"▲"</span> }.into_any() }
|
||||
if success {
|
||||
show_toast(NotificationLevel::Success, format!("Toplu işlem başarıyla tamamlandı: {}", action));
|
||||
selected_hashes.update(|s| s.clear());
|
||||
} else {
|
||||
show_toast(NotificationLevel::Error, "Bazı işlemler başarısız oldu.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let on_action = Callback::new(move |(action, hash): (String, String)| {
|
||||
@@ -132,78 +206,229 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="h-full bg-background relative flex flex-col overflow-hidden">
|
||||
// --- DESKTOP VIEW ---
|
||||
<div class="hidden md:flex flex-col h-full overflow-hidden">
|
||||
// Header
|
||||
<div class="flex items-center text-xs uppercase text-muted-foreground border-b border-border bg-muted/50 h-9 shrink-0 px-2 font-medium">
|
||||
<div class="flex-1 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Name)>
|
||||
"Name" {move || sort_arrow(SortColumn::Name)}
|
||||
</div>
|
||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Size)>
|
||||
"Size" {move || sort_arrow(SortColumn::Size)}
|
||||
</div>
|
||||
<div class="w-48 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Progress)>
|
||||
"Progress" {move || sort_arrow(SortColumn::Progress)}
|
||||
</div>
|
||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Status)>
|
||||
"Status" {move || sort_arrow(SortColumn::Status)}
|
||||
</div>
|
||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
||||
"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}
|
||||
</div>
|
||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
||||
"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}
|
||||
</div>
|
||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::ETA)>
|
||||
"ETA" {move || sort_arrow(SortColumn::ETA)}
|
||||
</div>
|
||||
<div class="w-32 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
||||
"Date" {move || sort_arrow(SortColumn::AddedDate)}
|
||||
</div>
|
||||
<div class="h-full bg-background relative flex flex-col overflow-hidden px-4 py-4 gap-4">
|
||||
// --- TOPBAR ---
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 flex-1 max-w-md">
|
||||
<Input
|
||||
class="h-9"
|
||||
placeholder="Torrent ara..."
|
||||
bind_value=store.search_query
|
||||
/>
|
||||
</div>
|
||||
|
||||
// Regular List
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
||||
let on_action = on_action.clone();
|
||||
move |hash| {
|
||||
let h = hash.clone();
|
||||
view! {
|
||||
<TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
|
||||
<TorrentRow hash=hash.clone() />
|
||||
</TorrentContextMenu>
|
||||
}
|
||||
}
|
||||
} />
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when=move || has_selection.get()>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="gap-2 bg-secondary text-secondary-foreground border-none hover:bg-secondary/80">
|
||||
<Ellipsis class="size-4" />
|
||||
{move || format!("Toplu İşlem ({})", selected_count.get())}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-48">
|
||||
<DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel>
|
||||
<DropdownMenuGroup class="mt-2">
|
||||
<DropdownMenuItem on:click=move |_| bulk_action("start")>
|
||||
<Play class="mr-2 size-4" /> "Başlat"
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem on:click=move |_| bulk_action("stop")>
|
||||
<Square class="mr-2 size-4" /> "Durdur"
|
||||
</DropdownMenuItem>
|
||||
|
||||
<div class="my-1 h-px bg-border" />
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger class="w-full text-left">
|
||||
<div class="inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm transition-colors text-destructive hover:bg-destructive/10 focus:bg-destructive/10">
|
||||
<Trash2 class="size-4" /> "Toplu Sil"
|
||||
</div>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>"Toplu Silme Onayı"</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{move || format!("Seçili {} torrent silinecek. Bu işlem geri alınamaz.", selected_count.get())}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogClose>"İptal"</AlertDialogClose>
|
||||
<Button variant=ButtonVariant::Destructive on:click=move |_| bulk_action("delete")>
|
||||
"Sil"
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Show>
|
||||
|
||||
<MultiSelect values=visible_columns>
|
||||
<MultiSelectTrigger class="w-[140px] h-9">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<Settings2 class="size-4" />
|
||||
"Sütunlar"
|
||||
</div>
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent>
|
||||
<MultiSelectGroup>
|
||||
{ALL_COLUMNS.into_iter().map(|(id, label)| {
|
||||
let id_val = id.to_string();
|
||||
view! {
|
||||
<MultiSelectItem>
|
||||
<MultiSelectOption value=id_val.clone() attr:disabled=move || id_val == "Name">
|
||||
{label}
|
||||
</MultiSelectOption>
|
||||
</MultiSelectItem>
|
||||
}.into_any()
|
||||
}).collect_view()}
|
||||
</MultiSelectGroup>
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// --- MOBILE VIEW ---
|
||||
<div class="md:hidden flex flex-col h-full bg-muted/10 relative overflow-hidden">
|
||||
<div class="flex-1 overflow-y-auto p-3 min-h-0">
|
||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
||||
let on_action = on_action.clone();
|
||||
move |hash| {
|
||||
let h = hash.clone();
|
||||
view! {
|
||||
<div class="pb-3">
|
||||
<TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
|
||||
<TorrentCard hash=hash.clone() />
|
||||
</TorrentContextMenu>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
} />
|
||||
// --- MAIN TABLE ---
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<DataTableWrapper class="h-full bg-card/50">
|
||||
<div class="h-full overflow-auto">
|
||||
<DataTable>
|
||||
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
||||
<DataTableRow class="hover:bg-transparent">
|
||||
<DataTableHead class="w-12 px-4">
|
||||
<Checkbox
|
||||
checked=Signal::derive(move || {
|
||||
let hashes = filtered_hashes.get();
|
||||
!hashes.is_empty() && selected_count.get() == hashes.len()
|
||||
})
|
||||
on_checked_change=handle_select_all
|
||||
/>
|
||||
</DataTableHead>
|
||||
|
||||
{move || visible_columns.get().contains("Name").then(|| view! {
|
||||
<DataTableHead class="cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
|
||||
<div class="flex items-center gap-2">"Name" {move || sort_icon(SortColumn::Name)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("Size").then(|| view! {
|
||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Size)>
|
||||
<div class="flex items-center gap-2">"Size" {move || sort_icon(SortColumn::Size)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("Progress").then(|| view! {
|
||||
<DataTableHead class="w-48 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Progress)>
|
||||
<div class="flex items-center gap-2">"Progress" {move || sort_icon(SortColumn::Progress)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("Status").then(|| view! {
|
||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Status)>
|
||||
<div class="flex items-center gap-2">"Status" {move || sort_icon(SortColumn::Status)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("DownSpeed").then(|| view! {
|
||||
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
||||
<div class="flex items-center justify-end gap-2">"DL Speed" {move || sort_icon(SortColumn::DownSpeed)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("UpSpeed").then(|| view! {
|
||||
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
||||
<div class="flex items-center justify-end gap-2">"UP Speed" {move || sort_icon(SortColumn::UpSpeed)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("ETA").then(|| view! {
|
||||
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::ETA)>
|
||||
<div class="flex items-center justify-end gap-2">"ETA" {move || sort_icon(SortColumn::ETA)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("AddedDate").then(|| view! {
|
||||
<DataTableHead class="w-32 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
||||
<div class="flex items-center justify-end gap-2">"Date" {move || sort_icon(SortColumn::AddedDate)}</div>
|
||||
</DataTableHead>
|
||||
}).into_any()}
|
||||
</DataTableRow>
|
||||
</DataTableHeader>
|
||||
<DataTableBody>
|
||||
<Show
|
||||
when=move || !filtered_hashes.get().is_empty()
|
||||
fallback=move || view! {
|
||||
<DataTableRow class="hover:bg-transparent">
|
||||
<DataTableCell attr:colspan="10" class="h-[400px]">
|
||||
<Empty class="h-full">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant=EmptyMediaVariant::Icon>
|
||||
<Inbox class="size-10 text-muted-foreground" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>"Torrent Bulunamadı"</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{move || {
|
||||
let query = store.search_query.get();
|
||||
if query.is_empty() { "Henüz torrent bulunmuyor.".to_string() }
|
||||
else { "Arama kriterlerinize uygun sonuç bulunamadı.".to_string() }
|
||||
}}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
}.into_any()
|
||||
>
|
||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
||||
let on_action = on_action.clone();
|
||||
move |hash| {
|
||||
let h = hash.clone();
|
||||
let is_selected = Signal::derive(move || {
|
||||
selected_hashes.with(|selected| selected.contains(&h))
|
||||
});
|
||||
let h_for_change = hash.clone();
|
||||
view! {
|
||||
<TorrentRow
|
||||
hash=hash.clone()
|
||||
on_action=on_action.clone()
|
||||
is_selected=is_selected
|
||||
visible_columns=visible_columns
|
||||
on_select=Callback::new(move |checked| {
|
||||
selected_hashes.update(|selected| {
|
||||
if checked { selected.insert(h_for_change.clone()); }
|
||||
else { selected.remove(&h_for_change); }
|
||||
});
|
||||
})
|
||||
/>
|
||||
}
|
||||
}
|
||||
} />
|
||||
</Show>
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</div>
|
||||
</DataTableWrapper>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center justify-between px-2 py-1 text-[11px] text-muted-foreground bg-muted/20 border rounded-md">
|
||||
<div class="flex gap-4">
|
||||
<span>{move || format!("Toplam: {} torrent", filtered_hashes.get().len())}</span>
|
||||
<Show when=move || has_selection.get()>
|
||||
<span class="text-primary font-medium">{move || format!("{} torrent seçili", selected_count.get())}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div>"VibeTorrent v3"</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TorrentRow(
|
||||
hash: String,
|
||||
on_action: Callback<(String, String)>,
|
||||
is_selected: Signal<bool>,
|
||||
visible_columns: RwSignal<HashSet<String>>,
|
||||
on_select: Callback<bool>,
|
||||
) -> impl IntoView {
|
||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||
let h = hash.clone();
|
||||
@@ -214,50 +439,114 @@ fn TorrentRow(
|
||||
view! {
|
||||
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
||||
{
|
||||
let on_action = on_action.clone();
|
||||
move || {
|
||||
let t = torrent.get().unwrap();
|
||||
let t_name = t.name.clone();
|
||||
let status_color = match t.status { shared::TorrentStatus::Seeding => "text-green-500", shared::TorrentStatus::Downloading => "text-blue-500", shared::TorrentStatus::Paused => "text-yellow-500", shared::TorrentStatus::Error => "text-red-500", _ => "text-muted-foreground" };
|
||||
|
||||
let is_active_selection = Memo::new(move |_| {
|
||||
let selected = store.selected_torrent.get();
|
||||
selected.as_deref() == Some(stored_hash.get_value().as_str())
|
||||
});
|
||||
|
||||
let t_name_stored = StoredValue::new(t_name.clone());
|
||||
let h_for_menu = stored_hash.get_value();
|
||||
|
||||
view! {
|
||||
<div
|
||||
class=move || {
|
||||
let selected = store.selected_torrent.get();
|
||||
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
|
||||
if is_selected {
|
||||
"flex items-center text-sm bg-primary/10 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full"
|
||||
} else {
|
||||
"flex items-center text-sm hover:bg-muted/50 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full"
|
||||
}
|
||||
}
|
||||
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||
>
|
||||
<div class="flex-1 min-w-0 px-2 font-medium truncate" title=t_name.clone()>{t_name.clone()}</div>
|
||||
<div class="w-24 px-2 font-mono text-xs text-muted-foreground">{format_bytes(t.size)}</div>
|
||||
<div class="w-48 px-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class={format!("w-24 px-2 text-xs font-medium {}", status_color)}>{format!("{:?}", t.status)}</div>
|
||||
<div class="w-24 px-2 text-right font-mono text-xs text-green-600 dark:text-green-500">{format_speed(t.down_rate)}</div>
|
||||
<div class="w-24 px-2 text-right font-mono text-xs text-blue-600 dark:text-blue-500">{format_speed(t.up_rate)}</div>
|
||||
<div class="w-24 px-2 text-right font-mono text-xs text-muted-foreground">{format_duration(t.eta)}</div>
|
||||
<div class="w-32 px-2 text-right font-mono text-xs text-muted-foreground">{format_date(t.added_date)}</div>
|
||||
</div>
|
||||
}
|
||||
<TorrentContextMenu torrent_hash=h_for_menu on_action=on_action.clone()>
|
||||
<DataTableRow
|
||||
class="cursor-pointer group h-10"
|
||||
attr:data-state=move || if is_selected.get() || is_active_selection.get() { "selected" } else { "" }
|
||||
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||
>
|
||||
<DataTableCell class="w-12 px-4">
|
||||
<Checkbox
|
||||
checked=is_selected
|
||||
on_checked_change=on_select
|
||||
/>
|
||||
</DataTableCell>
|
||||
|
||||
{move || visible_columns.get().contains("Name").then({
|
||||
move || view! {
|
||||
<DataTableCell class="font-medium truncate max-w-[200px] lg:max-w-md" attr:title=t_name_stored.get_value()>
|
||||
{t_name_stored.get_value()}
|
||||
</DataTableCell>
|
||||
}
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("Size").then({
|
||||
let size_bytes = t.size;
|
||||
move || {
|
||||
let size_str = format_bytes(size_bytes);
|
||||
view! { <DataTableCell class="font-mono text-xs text-muted-foreground whitespace-nowrap">{size_str}</DataTableCell> }
|
||||
}
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("Progress").then({
|
||||
let percent = t.percent_complete;
|
||||
move || view! {
|
||||
<DataTableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden min-w-[80px]">
|
||||
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", percent)></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", percent)}</span>
|
||||
</div>
|
||||
</DataTableCell>
|
||||
}
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("Status").then({
|
||||
let status_text = format!("{:?}", t.status);
|
||||
let color = status_color;
|
||||
move || view! { <DataTableCell class={format!("text-xs font-semibold whitespace-nowrap {}", color)}>{status_text.clone()}</DataTableCell> }
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("DownSpeed").then({
|
||||
let rate = t.down_rate;
|
||||
move || {
|
||||
let speed_str = format_speed(rate);
|
||||
view! { <DataTableCell class="text-right font-mono text-xs text-green-600 dark:text-green-500 whitespace-nowrap">{speed_str}</DataTableCell> }
|
||||
}
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("UpSpeed").then({
|
||||
let rate = t.up_rate;
|
||||
move || {
|
||||
let speed_str = format_speed(rate);
|
||||
view! { <DataTableCell class="text-right font-mono text-xs text-blue-600 dark:text-blue-500 whitespace-nowrap">{speed_str}</DataTableCell> }
|
||||
}
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("ETA").then({
|
||||
let eta = t.eta;
|
||||
move || {
|
||||
let eta_str = format_duration(eta);
|
||||
view! { <DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">{eta_str}</DataTableCell> }
|
||||
}
|
||||
}).into_any()}
|
||||
|
||||
{move || visible_columns.get().contains("AddedDate").then({
|
||||
let date = t.added_date;
|
||||
move || {
|
||||
let date_str = format_date(date);
|
||||
view! { <DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">{date_str}</DataTableCell> }
|
||||
}
|
||||
}).into_any()}
|
||||
</DataTableRow>
|
||||
</TorrentContextMenu>
|
||||
}.into_any()
|
||||
}
|
||||
}
|
||||
</Show>
|
||||
}
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TorrentCard(
|
||||
hash: String,
|
||||
on_action: Callback<(String, String)>,
|
||||
) -> impl IntoView {
|
||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||
let h = hash.clone();
|
||||
@@ -268,53 +557,57 @@ fn TorrentCard(
|
||||
view! {
|
||||
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
||||
{
|
||||
let on_action = on_action.clone();
|
||||
move || {
|
||||
let t = torrent.get().unwrap();
|
||||
let t_name = t.name.clone();
|
||||
let status_badge_class = match t.status { shared::TorrentStatus::Seeding => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border-green-200 dark:border-green-800", shared::TorrentStatus::Downloading => "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800", shared::TorrentStatus::Paused => "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800", shared::TorrentStatus::Error => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800", _ => "bg-muted text-muted-foreground" };
|
||||
let h_for_menu = stored_hash.get_value();
|
||||
|
||||
view! {
|
||||
<div
|
||||
class=move || {
|
||||
let selected = store.selected_torrent.get();
|
||||
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
|
||||
if is_selected {
|
||||
"ring-2 ring-primary rounded-lg transition-all"
|
||||
} else {
|
||||
"transition-all"
|
||||
<TorrentContextMenu torrent_hash=h_for_menu on_action=on_action.clone()>
|
||||
<div
|
||||
class=move || {
|
||||
let selected = store.selected_torrent.get();
|
||||
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
|
||||
if is_selected {
|
||||
"ring-2 ring-primary rounded-lg transition-all"
|
||||
} else {
|
||||
"transition-all"
|
||||
}
|
||||
}
|
||||
}
|
||||
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||
>
|
||||
<Card class="h-full select-none cursor-pointer hover:border-primary transition-colors">
|
||||
<CardHeader class="p-3 pb-0">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<CardTitle class="text-sm font-medium leading-tight line-clamp-2">{t_name.clone()}</CardTitle>
|
||||
<div class={format!("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 {}", status_badge_class)}>{format!("{:?}", t.status)}</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-3 pt-2 gap-3 flex flex-col">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>{format_bytes(t.size)}</span>
|
||||
<span>{format!("{:.1}%", t.percent_complete)}</span>
|
||||
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||
>
|
||||
<Card class="h-full select-none cursor-pointer hover:border-primary transition-colors">
|
||||
<CardHeader class="p-3 pb-0">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<CardTitle class="text-sm font-medium leading-tight line-clamp-2">{t_name.clone()}</CardTitle>
|
||||
<div class={format!("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 {}", status_badge_class)}>{format!("{:?}", t.status)}</div>
|
||||
</div>
|
||||
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
|
||||
</CardHeader>
|
||||
<CardBody class="p-3 pt-2 gap-3 flex flex-col">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>{format_bytes(t.size)}</span>
|
||||
<span>{format!("{:.1}%", t.percent_complete)}</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-2 text-[10px] font-mono text-muted-foreground pt-1 border-t border-border/50">
|
||||
<div class="flex flex-col text-blue-600 dark:text-blue-500"><span>"DL"</span><span>{format_speed(t.down_rate)}</span></div>
|
||||
<div class="flex flex-col text-green-600 dark:text-green-500"><span>"UP"</span><span>{format_speed(t.up_rate)}</span></div>
|
||||
<div class="flex flex-col"><span>"ETA"</span><span>{format_duration(t.eta)}</span></div>
|
||||
<div class="flex flex-col text-right"><span>"DATE"</span><span>{format_date(t.added_date)}</span></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
<div class="grid grid-cols-4 gap-2 text-[10px] font-mono text-muted-foreground pt-1 border-t border-border/50">
|
||||
<div class="flex flex-col text-blue-600 dark:text-blue-500"><span>"DL"</span><span>{format_speed(t.down_rate)}</span></div>
|
||||
<div class="flex flex-col text-green-600 dark:text-green-500"><span>"UP"</span><span>{format_speed(t.up_rate)}</span></div>
|
||||
<div class="flex flex-col"><span>"ETA"</span><span>{format_duration(t.eta)}</span></div>
|
||||
<div class="flex flex-col text-right"><span>"DATE"</span><span>{format_date(t.added_date)}</span></div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</TorrentContextMenu>
|
||||
}.into_any()
|
||||
}
|
||||
}
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
}.into_any()
|
||||
}
|
||||
94
frontend/src/components/ui/alert_dialog.rs
Normal file
94
frontend/src/components/ui/alert_dialog.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::components::ui::button::{ButtonSize, ButtonVariant};
|
||||
use crate::components::ui::dialog::{
|
||||
Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialog(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
view! { <Dialog class=class>{children()}</Dialog> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogTrigger(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
|
||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<DialogTrigger class=class variant=variant size=size>
|
||||
{children()}
|
||||
</DialogTrigger>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<DialogContent class=class close_on_backdrop_click=false data_name_prefix="AlertDialog">
|
||||
{children()}
|
||||
</DialogContent>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogBody(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<DialogBody class=class attr:data-name="AlertDialogBody">
|
||||
{children()}
|
||||
</DialogBody>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<DialogHeader class=class attr:data-name="AlertDialogHeader">
|
||||
{children()}
|
||||
</DialogHeader>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogTitle(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<DialogTitle class=class attr:data-name="AlertDialogTitle">
|
||||
{children()}
|
||||
</DialogTitle>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogDescription(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<DialogDescription class=class attr:data-name="AlertDialogDescription">
|
||||
{children()}
|
||||
</DialogDescription>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogFooter(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<DialogFooter class=class attr:data-name="AlertDialogFooter">
|
||||
{children()}
|
||||
</DialogFooter>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogClose(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
|
||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<DialogClose class=class variant=variant size=size>
|
||||
{children()}
|
||||
</DialogClose>
|
||||
}
|
||||
}
|
||||
39
frontend/src/components/ui/button.rs
Normal file
39
frontend/src/components/ui/button.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_ui::variants;
|
||||
|
||||
// TODO 💪 Loading state (demo_use_timeout_fn.rs and demo_button.rs)
|
||||
|
||||
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]", // Using hover:cursor-pointer as workaround for href_support.
|
||||
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",
|
||||
Accent: "bg-accent text-accent-foreground hover:bg-accent/80",
|
||||
Link: "text-primary underline-offset-4 hover:underline",
|
||||
//
|
||||
Warning: "bg-warning text-warning-foreground hover:bg-warning/90",
|
||||
Success: "bg-success text-success-foreground hover:bg-success/90",
|
||||
Bordered: "bg-transparent border border-zinc-200 text-muted-foreground",
|
||||
},
|
||||
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",
|
||||
//
|
||||
Mobile: "px-6 py-3 rounded-[24px]",
|
||||
Badge: "px-2.5 py-0.5 text-xs"
|
||||
}
|
||||
},
|
||||
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::*;
|
||||
43
frontend/src/components/ui/checkbox.rs
Normal file
43
frontend/src/components/ui/checkbox.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use icons::Check;
|
||||
use leptos::prelude::*;
|
||||
use tw_merge::tw_merge;
|
||||
|
||||
#[component]
|
||||
pub fn Checkbox(
|
||||
#[prop(into, optional)] class: String,
|
||||
#[prop(into, optional)] checked: Signal<bool>,
|
||||
#[prop(into, optional)] disabled: Signal<bool>,
|
||||
#[prop(into, optional)] on_checked_change: Option<Callback<bool>>,
|
||||
#[prop(into, optional, default = "Checkbox".to_string())] aria_label: String,
|
||||
) -> impl IntoView {
|
||||
let checked_state = move || if checked.get() { "checked" } else { "unchecked" };
|
||||
|
||||
let checkbox_class = tw_merge!(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
class
|
||||
);
|
||||
|
||||
view! {
|
||||
<button
|
||||
data-name="Checkbox"
|
||||
class=checkbox_class
|
||||
data-state=checked_state
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked=move || checked.get().to_string()
|
||||
aria-label=aria_label
|
||||
disabled=move || disabled.get()
|
||||
on:click=move |_| {
|
||||
if !disabled.get() {
|
||||
if let Some(callback) = on_checked_change {
|
||||
callback.run(!checked.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
<span data-name="CheckboxIndicator" class="flex justify-center items-center text-current transition-none">
|
||||
{move || { checked.get().then(|| view! { <Check class="size-3.5".to_string() /> }) }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
434
frontend/src/components/ui/context_menu.rs
Normal file
434
frontend/src/components/ui/context_menu.rs
Normal file
@@ -0,0 +1,434 @@
|
||||
use icons::ChevronRight;
|
||||
use leptos::context::Provider;
|
||||
use leptos::prelude::*;
|
||||
use leptos_ui::clx;
|
||||
use tw_merge::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
use crate::components::hooks::use_random::use_random_id_for;
|
||||
|
||||
/// Programmatically close any open context menu.
|
||||
pub fn close_context_menu() {
|
||||
let Some(document) = window().document() else {
|
||||
return;
|
||||
};
|
||||
let Some(menu) = document.query_selector("[data-target='target__context'][data-state='open']").ok().flatten()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = menu.set_attribute("data-state", "closed");
|
||||
if let Some(el) = menu.dyn_ref::<web_sys::HtmlElement>() {
|
||||
let _ = el.style().set_property("pointer-events", "none");
|
||||
}
|
||||
}
|
||||
|
||||
mod components {
|
||||
use super::*;
|
||||
clx! {ContextMenuLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
|
||||
clx! {ContextMenuGroup, ul, "group"}
|
||||
clx! {ContextMenuItem, li, "inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4"}
|
||||
clx! {ContextMenuSubContent, ul, "context__menu_sub_content", "rounded-md border bg-card shadow-lg p-1 absolute z-[100] min-w-[160px] opacity-0 invisible translate-x-[-8px] transition-all duration-200 ease-out pointer-events-none"}
|
||||
clx! {ContextMenuLink, a, "w-full inline-flex gap-2 items-center"}
|
||||
}
|
||||
|
||||
pub use components::*;
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenuAction(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional, into)] aria_selected: Option<Signal<bool>>,
|
||||
#[prop(optional, into)] href: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let _ctx = expect_context::<ContextMenuContext>();
|
||||
|
||||
let class = tw_merge!(
|
||||
"inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
class
|
||||
);
|
||||
|
||||
let aria_selected_attr = move || aria_selected.map(|s| s.get()).unwrap_or(false).to_string();
|
||||
|
||||
if let Some(href) = href {
|
||||
view! {
|
||||
<a
|
||||
data-name="ContextMenuAction"
|
||||
class=class
|
||||
href=href
|
||||
aria-selected=aria_selected_attr
|
||||
data-context-close="true"
|
||||
>
|
||||
{children()}
|
||||
</a>
|
||||
}
|
||||
.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
data-name="ContextMenuAction"
|
||||
class=class
|
||||
data-context-close="true"
|
||||
aria-selected=aria_selected_attr
|
||||
>
|
||||
{children()}
|
||||
</button>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenuHoldAction(
|
||||
children: Children,
|
||||
#[prop(into)] on_hold_complete: Callback<()>,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = 1000)] hold_duration: u64,
|
||||
) -> impl IntoView {
|
||||
let is_holding = RwSignal::new(false);
|
||||
let progress = RwSignal::new(0.0);
|
||||
|
||||
let on_mousedown = move |_| {
|
||||
is_holding.set(true);
|
||||
progress.set(0.0);
|
||||
};
|
||||
|
||||
let on_mouseup = move |_| {
|
||||
is_holding.set(false);
|
||||
progress.set(0.0);
|
||||
};
|
||||
|
||||
Effect::new(move |_| {
|
||||
if is_holding.get() {
|
||||
let start_time = js_sys::Date::now();
|
||||
let duration = hold_duration as f64;
|
||||
|
||||
leptos::task::spawn_local(async move {
|
||||
while is_holding.get_untracked() {
|
||||
let now = js_sys::Date::now();
|
||||
let elapsed = now - start_time;
|
||||
let p = (elapsed / duration).min(1.0);
|
||||
progress.set(p * 100.0);
|
||||
|
||||
if p >= 1.0 {
|
||||
on_hold_complete.run(());
|
||||
is_holding.set(false);
|
||||
close_context_menu();
|
||||
break;
|
||||
}
|
||||
gloo_timers::future::TimeoutFuture::new(16).await; // ~60fps
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let class = tw_merge!(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors overflow-hidden",
|
||||
class
|
||||
);
|
||||
|
||||
view! {
|
||||
<div
|
||||
class=class
|
||||
on:mousedown=on_mousedown
|
||||
on:mouseup=on_mouseup
|
||||
on:mouseleave=on_mouseup
|
||||
on:touchstart=move |_| on_mousedown(web_sys::MouseEvent::new("mousedown").unwrap())
|
||||
on:touchend=move |_| on_mouseup(web_sys::MouseEvent::new("mouseup").unwrap())
|
||||
>
|
||||
// Progress background
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 bg-destructive/20 transition-all duration-75 ease-linear pointer-events-none"
|
||||
style=move || format!("width: {}%;", progress.get())
|
||||
/>
|
||||
<span class="relative z-10 flex items-center gap-2 w-full">
|
||||
{children()}
|
||||
</span>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ContextMenuContext {
|
||||
target_id: String,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenu(children: Children) -> impl IntoView {
|
||||
let context_target_id = use_random_id_for("context");
|
||||
|
||||
let ctx = ContextMenuContext { target_id: context_target_id.clone() };
|
||||
|
||||
view! {
|
||||
<Provider value=ctx>
|
||||
<style>
|
||||
"
|
||||
/* Submenu Styles */
|
||||
.context__menu_sub_content {
|
||||
position: absolute;
|
||||
inset-inline-start: calc(100% + 8px);
|
||||
inset-block-start: -4px;
|
||||
z-index: 100;
|
||||
min-inline-size: 160px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateX(-8px);
|
||||
transition: all 0.2s ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.context__menu_sub_trigger:hover .context__menu_sub_content {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
"
|
||||
</style>
|
||||
|
||||
<div data-name="ContextMenu" class="contents">
|
||||
{children()}
|
||||
</div>
|
||||
</Provider>
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper that triggers the context menu on right-click.
|
||||
/// The `on_open` callback is triggered when the context menu opens (right-click).
|
||||
#[component]
|
||||
pub fn ContextMenuTrigger(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional)] on_open: Option<Callback<()>>,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<ContextMenuContext>();
|
||||
let trigger_class = tw_merge!("contents", class);
|
||||
|
||||
view! {
|
||||
<div
|
||||
class=trigger_class
|
||||
data-name="ContextMenuTrigger"
|
||||
data-context-trigger=ctx.target_id
|
||||
on:contextmenu=move |e: web_sys::MouseEvent| {
|
||||
if let Some(cb) = on_open {
|
||||
cb.run(());
|
||||
}
|
||||
}
|
||||
>
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Content of the context menu that appears on right-click.
|
||||
/// The `on_close` callback is triggered when the menu closes (click outside, ESC key, or action click).
|
||||
#[component]
|
||||
pub fn ContextMenuContent(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional)] on_close: Option<Callback<()>>,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<ContextMenuContext>();
|
||||
|
||||
let base_classes = "fixed z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100";
|
||||
|
||||
let class = tw_merge!(base_classes, class);
|
||||
|
||||
let target_id_for_script = ctx.target_id.clone();
|
||||
|
||||
view! {
|
||||
<div
|
||||
data-name="ContextMenuContent"
|
||||
class=class
|
||||
// Listen for custom 'contextmenuclose' event dispatched by JS when menu closes
|
||||
on:contextmenuclose=move |_: web_sys::CustomEvent| {
|
||||
if let Some(cb) = on_close {
|
||||
cb.run(());
|
||||
}
|
||||
}
|
||||
id=ctx.target_id
|
||||
data-target="target__context"
|
||||
data-state="closed"
|
||||
style="pointer-events: none;"
|
||||
>
|
||||
{children()}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{format!(
|
||||
r#"
|
||||
(function() {{
|
||||
const setupContextMenu = () => {{
|
||||
const menu = document.querySelector('#{}');
|
||||
const trigger = document.querySelector('[data-context-trigger="{}"]');
|
||||
|
||||
if (!menu || !trigger) {{
|
||||
setTimeout(setupContextMenu, 50);
|
||||
return;
|
||||
}}
|
||||
|
||||
if (menu.hasAttribute('data-initialized')) {{
|
||||
return;
|
||||
}}
|
||||
menu.setAttribute('data-initialized', 'true');
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
const updatePosition = (x, y) => {{
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
// Calculate position, ensuring menu stays within viewport
|
||||
let left = x;
|
||||
let top = y;
|
||||
|
||||
// Adjust if menu would go off right edge
|
||||
if (x + menuRect.width > viewportWidth) {{
|
||||
left = x - menuRect.width;
|
||||
}}
|
||||
|
||||
// Adjust if menu would go off bottom edge
|
||||
if (y + menuRect.height > viewportHeight) {{
|
||||
top = y - menuRect.height;
|
||||
}}
|
||||
|
||||
menu.style.left = `${{left}}px`;
|
||||
menu.style.top = `${{top}}px`;
|
||||
menu.style.transformOrigin = 'top left';
|
||||
}};
|
||||
|
||||
const openMenu = (x, y) => {{
|
||||
isOpen = true;
|
||||
|
||||
// Close any other open context menus
|
||||
const allMenus = document.querySelectorAll('[data-target="target__context"]');
|
||||
allMenus.forEach(m => {{
|
||||
if (m !== menu && m.getAttribute('data-state') === 'open') {{
|
||||
m.setAttribute('data-state', 'closed');
|
||||
m.style.pointerEvents = 'none';
|
||||
}}
|
||||
}});
|
||||
|
||||
menu.setAttribute('data-state', 'open');
|
||||
menu.style.visibility = 'hidden';
|
||||
menu.style.pointerEvents = 'auto';
|
||||
|
||||
// Force reflow
|
||||
menu.offsetHeight;
|
||||
|
||||
updatePosition(x, y);
|
||||
menu.style.visibility = 'visible';
|
||||
|
||||
// Lock scroll
|
||||
if (window.ScrollLock) {{
|
||||
window.ScrollLock.lock();
|
||||
}}
|
||||
|
||||
setTimeout(() => {{
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener('contextmenu', handleContextOutside);
|
||||
}}, 0);
|
||||
}};
|
||||
|
||||
const closeMenu = () => {{
|
||||
isOpen = false;
|
||||
menu.setAttribute('data-state', 'closed');
|
||||
menu.style.pointerEvents = 'none';
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
document.removeEventListener('contextmenu', handleContextOutside);
|
||||
|
||||
// Dispatch custom event for Leptos to listen to
|
||||
menu.dispatchEvent(new CustomEvent('contextmenuclose', {{ bubbles: false }}));
|
||||
|
||||
if (window.ScrollLock) {{
|
||||
window.ScrollLock.unlock(200);
|
||||
}}
|
||||
}};
|
||||
|
||||
const handleClickOutside = (e) => {{
|
||||
if (!menu.contains(e.target)) {{
|
||||
closeMenu();
|
||||
}}
|
||||
}};
|
||||
|
||||
const handleContextOutside = (e) => {{
|
||||
if (!trigger.contains(e.target)) {{
|
||||
closeMenu();
|
||||
}}
|
||||
}};
|
||||
|
||||
// Right-click on trigger
|
||||
trigger.addEventListener('contextmenu', (e) => {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (isOpen) {{
|
||||
closeMenu();
|
||||
}}
|
||||
openMenu(e.clientX, e.clientY);
|
||||
}});
|
||||
|
||||
// Close when action is clicked
|
||||
const actions = menu.querySelectorAll('[data-context-close]');
|
||||
actions.forEach(action => {{
|
||||
action.addEventListener('click', () => {{
|
||||
closeMenu();
|
||||
}});
|
||||
}});
|
||||
|
||||
// Handle ESC key
|
||||
document.addEventListener('keydown', (e) => {{
|
||||
if (e.key === 'Escape' && isOpen) {{
|
||||
e.preventDefault();
|
||||
closeMenu();
|
||||
}}
|
||||
}});
|
||||
}};
|
||||
|
||||
if (document.readyState === 'loading') {{
|
||||
document.addEventListener('DOMContentLoaded', setupContextMenu);
|
||||
}} else {{
|
||||
setupContextMenu();
|
||||
}}
|
||||
}})();
|
||||
"#,
|
||||
target_id_for_script,
|
||||
target_id_for_script,
|
||||
)}
|
||||
</script>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenuSub(children: Children) -> impl IntoView {
|
||||
clx! {ContextMenuSubRoot, li, "context__menu_sub_trigger", " relative inline-flex relative gap-2 items-center py-1.5 px-2 w-full text-sm no-underline rounded-sm transition-colors duration-200 cursor-pointer text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground"}
|
||||
|
||||
view! { <ContextMenuSubRoot>{children()}</ContextMenuSubRoot> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenuSubTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!("flex items-center justify-between w-full", class);
|
||||
|
||||
view! {
|
||||
<span data-name="ContextMenuSubTrigger" class=class>
|
||||
<span class="flex gap-2 items-center">{children()}</span>
|
||||
<ChevronRight class="opacity-70 size-4" />
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenuSubItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!(
|
||||
"inline-flex gap-2 items-center w-full rounded-sm px-3 py-2 text-sm transition-all duration-150 ease text-popover-foreground hover:bg-accent hover:text-accent-foreground cursor-pointer hover:translate-x-[2px]",
|
||||
class
|
||||
);
|
||||
|
||||
view! {
|
||||
<li data-name="ContextMenuSubItem" class=class data-context-close="true">
|
||||
{children()}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
6
frontend/src/components/ui/data_table.rs
Normal file
6
frontend/src/components/ui/data_table.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// * Reuse @table.rs
|
||||
pub use crate::components::ui::table::{
|
||||
Table as DataTable, TableBody as DataTableBody, TableCaption as DataTableCaption, TableCell as DataTableCell,
|
||||
TableFooter as DataTableFooter, TableHead as DataTableHead, TableHeader as DataTableHeader,
|
||||
TableRow as DataTableRow, TableWrapper as DataTableWrapper,
|
||||
};
|
||||
251
frontend/src/components/ui/dialog.rs
Normal file
251
frontend/src/components/ui/dialog.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
use icons::X;
|
||||
use leptos::context::Provider;
|
||||
use leptos::prelude::*;
|
||||
use leptos_ui::clx;
|
||||
use tw_merge::*;
|
||||
|
||||
use crate::components::hooks::use_random::use_random_id_for;
|
||||
use crate::components::ui::button::{Button, ButtonSize, ButtonVariant};
|
||||
|
||||
mod components {
|
||||
use super::*;
|
||||
clx! {DialogBody, div, "flex flex-col gap-4"}
|
||||
clx! {DialogHeader, div, "flex flex-col gap-2 text-center sm:text-left"}
|
||||
clx! {DialogTitle, h3, "text-lg leading-none font-semibold"}
|
||||
clx! {DialogDescription, p, "text-muted-foreground text-sm"}
|
||||
clx! {DialogFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"}
|
||||
}
|
||||
|
||||
pub use components::*;
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DialogContext {
|
||||
target_id: String,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Dialog(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let dialog_target_id = use_random_id_for("dialog");
|
||||
|
||||
let ctx = DialogContext { target_id: dialog_target_id.clone() };
|
||||
|
||||
let merged_class = tw_merge!("w-fit", class);
|
||||
|
||||
view! {
|
||||
<Provider value=ctx>
|
||||
<div class=merged_class data-name="__Dialog">
|
||||
{children()}
|
||||
</div>
|
||||
</Provider>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DialogTrigger(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
|
||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<DialogContext>();
|
||||
let trigger_id = format!("trigger_{}", ctx.target_id);
|
||||
|
||||
view! {
|
||||
<Button
|
||||
class=class
|
||||
attr:id=trigger_id
|
||||
attr:tabindex="0"
|
||||
attr:data-dialog-trigger=ctx.target_id
|
||||
variant=variant
|
||||
size=size
|
||||
>
|
||||
{children()}
|
||||
</Button>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DialogContent(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(into, optional)] hide_close_button: Option<bool>,
|
||||
#[prop(default = true)] close_on_backdrop_click: bool,
|
||||
#[prop(default = "Dialog")] data_name_prefix: &'static str,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<DialogContext>();
|
||||
let merged_class = tw_merge!(
|
||||
// "flex flex-col gap-4", // TODO 🐛 Bug when I try to have this.. Using DialogBody instead.
|
||||
"relative bg-background border rounded-2xl shadow-lg p-6 w-full max-w-[calc(100%-2rem)] max-h-[85vh] fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-100 transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100",
|
||||
class
|
||||
);
|
||||
|
||||
let backdrop_data_name = format!("{}Backdrop", data_name_prefix);
|
||||
let content_data_name = format!("{}Content", data_name_prefix);
|
||||
|
||||
let target_id_clone = ctx.target_id.clone();
|
||||
let backdrop_id = format!("{}_backdrop", ctx.target_id);
|
||||
let target_id_for_script = ctx.target_id.clone();
|
||||
let backdrop_id_for_script = backdrop_id.clone();
|
||||
let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" };
|
||||
|
||||
view! {
|
||||
<script src="/hooks/lock_scroll.js"></script>
|
||||
|
||||
<div
|
||||
data-name=backdrop_data_name
|
||||
id=backdrop_id
|
||||
class="fixed inset-0 transition-opacity duration-200 pointer-events-none z-60 bg-black/50 data-[state=closed]:opacity-0 data-[state=open]:opacity-100"
|
||||
data-state="closed"
|
||||
/>
|
||||
|
||||
<div
|
||||
data-name=content_data_name
|
||||
class=merged_class
|
||||
id=ctx.target_id
|
||||
data-target="target__dialog"
|
||||
data-state="closed"
|
||||
data-backdrop=backdrop_behavior
|
||||
style="pointer-events: none;"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class=format!(
|
||||
"absolute top-4 right-4 p-1 rounded-sm focus:ring-2 focus:ring-offset-2 focus:outline-none [&_svg:not([class*='size-'])]:size-4 focus:ring-ring{}",
|
||||
if hide_close_button.unwrap_or(false) { " hidden" } else { "" },
|
||||
)
|
||||
data-dialog-close=target_id_clone.clone()
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<span class="hidden">"Close Dialog"</span>
|
||||
<X />
|
||||
</button>
|
||||
|
||||
{children()}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{format!(
|
||||
r#"
|
||||
(function() {{
|
||||
const setupDialog = () => {{
|
||||
const dialog = document.querySelector('#{}');
|
||||
const backdrop = document.querySelector('#{}');
|
||||
const trigger = document.querySelector('[data-dialog-trigger="{}"]');
|
||||
|
||||
if (!dialog || !backdrop || !trigger) {{
|
||||
setTimeout(setupDialog, 50);
|
||||
return;
|
||||
}}
|
||||
|
||||
if (dialog.hasAttribute('data-initialized')) {{
|
||||
return;
|
||||
}}
|
||||
dialog.setAttribute('data-initialized', 'true');
|
||||
|
||||
const openDialog = () => {{
|
||||
// Lock scrolling
|
||||
window.ScrollLock.lock();
|
||||
|
||||
dialog.setAttribute('data-state', 'open');
|
||||
backdrop.setAttribute('data-state', 'open');
|
||||
dialog.style.pointerEvents = 'auto';
|
||||
backdrop.style.pointerEvents = 'auto';
|
||||
}};
|
||||
|
||||
const closeDialog = () => {{
|
||||
dialog.setAttribute('data-state', 'closed');
|
||||
backdrop.setAttribute('data-state', 'closed');
|
||||
dialog.style.pointerEvents = 'none';
|
||||
backdrop.style.pointerEvents = 'none';
|
||||
|
||||
// Unlock scrolling after animation
|
||||
window.ScrollLock.unlock(200);
|
||||
}};
|
||||
|
||||
// Open dialog when trigger is clicked
|
||||
trigger.addEventListener('click', openDialog);
|
||||
|
||||
// Close buttons
|
||||
const closeButtons = dialog.querySelectorAll('[data-dialog-close]');
|
||||
closeButtons.forEach(btn => {{
|
||||
btn.addEventListener('click', closeDialog);
|
||||
}});
|
||||
|
||||
// Close on backdrop click (if data-backdrop="auto")
|
||||
backdrop.addEventListener('click', () => {{
|
||||
if (dialog.getAttribute('data-backdrop') === 'auto') {{
|
||||
closeDialog();
|
||||
}}
|
||||
}});
|
||||
|
||||
// Handle ESC key to close
|
||||
document.addEventListener('keydown', (e) => {{
|
||||
if (e.key === 'Escape' && dialog.getAttribute('data-state') === 'open') {{
|
||||
e.preventDefault();
|
||||
closeDialog();
|
||||
}}
|
||||
}});
|
||||
}};
|
||||
|
||||
if (document.readyState === 'loading') {{
|
||||
document.addEventListener('DOMContentLoaded', setupDialog);
|
||||
}} else {{
|
||||
setupDialog();
|
||||
}}
|
||||
}})();
|
||||
"#,
|
||||
target_id_for_script,
|
||||
backdrop_id_for_script,
|
||||
target_id_for_script,
|
||||
)}
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DialogClose(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
|
||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<DialogContext>();
|
||||
|
||||
view! {
|
||||
<Button
|
||||
class=class
|
||||
attr:data-dialog-close=ctx.target_id
|
||||
attr:aria-label="Close dialog"
|
||||
variant=variant
|
||||
size=size
|
||||
>
|
||||
{children()}
|
||||
</Button>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DialogAction(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = ButtonVariant::Default)] variant: ButtonVariant,
|
||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<DialogContext>();
|
||||
|
||||
view! {
|
||||
<Button
|
||||
class=class
|
||||
attr:data-dialog-close=ctx.target_id
|
||||
attr:aria-label="Close dialog"
|
||||
variant=variant
|
||||
size=size
|
||||
>
|
||||
{children()}
|
||||
</Button>
|
||||
}
|
||||
}
|
||||
536
frontend/src/components/ui/dropdown_menu.rs
Normal file
536
frontend/src/components/ui/dropdown_menu.rs
Normal file
@@ -0,0 +1,536 @@
|
||||
use icons::{Check, ChevronRight};
|
||||
use leptos::context::Provider;
|
||||
use leptos::prelude::*;
|
||||
use leptos_ui::clx;
|
||||
use tw_merge::*;
|
||||
|
||||
use crate::components::hooks::use_random::use_random_id_for;
|
||||
pub use crate::components::ui::separator::Separator as DropdownMenuSeparator;
|
||||
|
||||
mod components {
|
||||
use super::*;
|
||||
clx! {DropdownMenuLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
|
||||
clx! {DropdownMenuGroup, ul, "group"}
|
||||
clx! {DropdownMenuItem, li, "inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4"}
|
||||
clx! {DropdownMenuSubContent, ul, "dropdown__menu_sub_content", "rounded-md border bg-card shadow-lg p-1 absolute z-[100] min-w-[160px] opacity-0 invisible translate-x-[-8px] transition-all duration-200 ease-out pointer-events-none"}
|
||||
clx! {DropdownMenuLink, a, "w-full inline-flex gap-2 items-center"}
|
||||
}
|
||||
|
||||
pub use components::*;
|
||||
|
||||
/* ========================================================== */
|
||||
/* RADIO GROUP */
|
||||
/* ========================================================== */
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DropdownMenuRadioContext<T: Clone + PartialEq + Send + Sync + 'static> {
|
||||
value_signal: RwSignal<T>,
|
||||
}
|
||||
|
||||
/// A group of radio items where only one can be selected at a time.
|
||||
#[component]
|
||||
pub fn DropdownMenuRadioGroup<T>(
|
||||
children: Children,
|
||||
/// The signal holding the current selected value
|
||||
value: RwSignal<T>,
|
||||
) -> impl IntoView
|
||||
where
|
||||
T: Clone + PartialEq + Send + Sync + 'static,
|
||||
{
|
||||
let ctx = DropdownMenuRadioContext { value_signal: value };
|
||||
|
||||
view! {
|
||||
<Provider value=ctx>
|
||||
<ul data-name="DropdownMenuRadioGroup" role="group" class="group">
|
||||
{children()}
|
||||
</ul>
|
||||
</Provider>
|
||||
}
|
||||
}
|
||||
|
||||
/// A radio item that shows a checkmark when selected.
|
||||
#[component]
|
||||
pub fn DropdownMenuRadioItem<T>(
|
||||
children: Children,
|
||||
/// The value this item represents
|
||||
value: T,
|
||||
#[prop(optional, into)] class: String,
|
||||
) -> impl IntoView
|
||||
where
|
||||
T: Clone + PartialEq + Send + Sync + 'static,
|
||||
{
|
||||
let ctx = expect_context::<DropdownMenuRadioContext<T>>();
|
||||
|
||||
let value_for_check = value.clone();
|
||||
let value_for_click = value.clone();
|
||||
let is_selected = move || ctx.value_signal.get() == value_for_check;
|
||||
|
||||
let merged_class = tw_merge!(
|
||||
"group inline-flex gap-2 items-center w-full rounded-sm pl-2 pr-2 py-1.5 text-sm cursor-pointer no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
class
|
||||
);
|
||||
|
||||
view! {
|
||||
<li
|
||||
data-name="DropdownMenuRadioItem"
|
||||
class=merged_class
|
||||
role="menuitemradio"
|
||||
aria-checked=move || is_selected().to_string()
|
||||
data-dropdown-close="true"
|
||||
on:click=move |_| {
|
||||
ctx.value_signal.set(value_for_click.clone());
|
||||
}
|
||||
>
|
||||
{children()}
|
||||
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-checked:opacity-100" />
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
/// An action item in a dropdown menu (no checkmark, just triggers an action).
|
||||
#[component]
|
||||
pub fn DropdownMenuAction(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional, into)] href: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let _ctx = expect_context::<DropdownMenuContext>();
|
||||
|
||||
let class = tw_merge!(
|
||||
"inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground",
|
||||
class
|
||||
);
|
||||
|
||||
if let Some(href) = href {
|
||||
// Render as <a> tag when href is provided
|
||||
view! {
|
||||
<a data-name="DropdownMenuAction" class=class href=href data-dropdown-close="true">
|
||||
{children()}
|
||||
</a>
|
||||
|
||||
<script>
|
||||
{r#"
|
||||
(function() {
|
||||
const link = document.currentScript.previousElementSibling;
|
||||
if (!link) return;
|
||||
|
||||
link.addEventListener('click', function() {
|
||||
// Close dropdown on route change after navigation
|
||||
let currentPath = window.location.pathname;
|
||||
const checkRouteChange = () => {
|
||||
if (window.location.pathname !== currentPath) {
|
||||
currentPath = window.location.pathname;
|
||||
|
||||
// Find and close the dropdown
|
||||
const dropdown = link.closest('[data-target="target__dropdown"]');
|
||||
if (dropdown) {
|
||||
dropdown.setAttribute('data-state', 'closed');
|
||||
dropdown.style.pointerEvents = 'none';
|
||||
|
||||
// Unlock scroll
|
||||
if (window.ScrollLock) {
|
||||
window.ScrollLock.unlock(200);
|
||||
}
|
||||
}
|
||||
|
||||
clearInterval(routeCheckInterval);
|
||||
}
|
||||
};
|
||||
|
||||
const routeCheckInterval = setInterval(checkRouteChange, 50);
|
||||
|
||||
// Clear interval after 2 seconds to prevent memory leaks
|
||||
setTimeout(() => clearInterval(routeCheckInterval), 2000);
|
||||
});
|
||||
})();
|
||||
"#}
|
||||
</script>
|
||||
}
|
||||
.into_any()
|
||||
} else {
|
||||
// Render as <button> tag when no href
|
||||
view! {
|
||||
<button type="button" data-name="DropdownMenuAction" class=class data-dropdown-close="true">
|
||||
{children()}
|
||||
</button>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DropdownMenuAlign {
|
||||
#[default]
|
||||
Start,
|
||||
StartOuter,
|
||||
End,
|
||||
EndOuter,
|
||||
Center,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DropdownMenuContext {
|
||||
target_id: String,
|
||||
align: DropdownMenuAlign,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DropdownMenu(
|
||||
children: Children,
|
||||
#[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign,
|
||||
) -> impl IntoView {
|
||||
let dropdown_target_id = use_random_id_for("dropdown");
|
||||
|
||||
let ctx = DropdownMenuContext { target_id: dropdown_target_id.clone(), align };
|
||||
|
||||
view! {
|
||||
<Provider value=ctx>
|
||||
<style>
|
||||
"
|
||||
/* Submenu Styles */
|
||||
.dropdown__menu_sub_content {
|
||||
position: absolute;
|
||||
inset-inline-start: calc(100% + 8px);
|
||||
inset-block-start: -4px;
|
||||
z-index: 100;
|
||||
min-inline-size: 160px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateX(-8px);
|
||||
transition: all 0.2s ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown__menu_sub_trigger:hover .dropdown__menu_sub_content {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
"
|
||||
</style>
|
||||
|
||||
<div data-name="DropdownMenu">{children()}</div>
|
||||
</Provider>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DropdownMenuTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let ctx = expect_context::<DropdownMenuContext>();
|
||||
let button_class = tw_merge!(
|
||||
"px-4 py-2 h-9 inline-flex justify-center items-center text-sm font-medium whitespace-nowrap rounded-md transition-colors w-fit focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
|
||||
class
|
||||
);
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class=button_class
|
||||
data-name="DropdownMenuTrigger"
|
||||
data-dropdown-trigger=ctx.target_id
|
||||
tabindex="0"
|
||||
>
|
||||
{children()}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DropdownMenuPosition {
|
||||
#[default]
|
||||
Auto,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DropdownMenuContent(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = DropdownMenuPosition::default())] position: DropdownMenuPosition,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<DropdownMenuContext>();
|
||||
|
||||
let base_classes = "z-50 p-1 rounded-md border bg-card shadow-md h-fit fixed transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100";
|
||||
let width_class = match ctx.align {
|
||||
DropdownMenuAlign::Center => "min-w-full",
|
||||
_ => "w-[180px]",
|
||||
};
|
||||
|
||||
let class = tw_merge!(width_class, base_classes, class);
|
||||
|
||||
let target_id_for_script = ctx.target_id.clone();
|
||||
let align_for_script = match ctx.align {
|
||||
DropdownMenuAlign::Start => "start",
|
||||
DropdownMenuAlign::StartOuter => "start-outer",
|
||||
DropdownMenuAlign::End => "end",
|
||||
DropdownMenuAlign::EndOuter => "end-outer",
|
||||
DropdownMenuAlign::Center => "center",
|
||||
};
|
||||
|
||||
let position_for_script = match position {
|
||||
DropdownMenuPosition::Auto => "auto",
|
||||
DropdownMenuPosition::Top => "top",
|
||||
DropdownMenuPosition::Bottom => "bottom",
|
||||
};
|
||||
|
||||
view! {
|
||||
<div
|
||||
data-name="DropdownMenuContent"
|
||||
class=class
|
||||
id=ctx.target_id
|
||||
data-target="target__dropdown"
|
||||
data-state="closed"
|
||||
data-align=align_for_script
|
||||
data-position=position_for_script
|
||||
style="pointer-events: none;"
|
||||
>
|
||||
{children()}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{format!(
|
||||
r#"
|
||||
(function() {{
|
||||
const setupDropdown = () => {{
|
||||
const dropdown = document.querySelector('#{}');
|
||||
const trigger = document.querySelector('[data-dropdown-trigger="{}"]');
|
||||
|
||||
if (!dropdown || !trigger) {{
|
||||
setTimeout(setupDropdown, 50);
|
||||
return;
|
||||
}}
|
||||
|
||||
if (dropdown.hasAttribute('data-initialized')) {{
|
||||
return;
|
||||
}}
|
||||
dropdown.setAttribute('data-initialized', 'true');
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
const updatePosition = () => {{
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const dropdownRect = dropdown.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom;
|
||||
const spaceAbove = triggerRect.top;
|
||||
|
||||
const align = dropdown.getAttribute('data-align') || 'start';
|
||||
const position = dropdown.getAttribute('data-position') || 'auto';
|
||||
|
||||
// Determine if we should position above
|
||||
let shouldPositionAbove = false;
|
||||
if (position === 'top') {{
|
||||
shouldPositionAbove = true;
|
||||
}} else if (position === 'bottom') {{
|
||||
shouldPositionAbove = false;
|
||||
}} else {{
|
||||
// Auto: position above if there's space above AND not enough space below
|
||||
shouldPositionAbove = spaceAbove >= dropdownRect.height && spaceBelow < dropdownRect.height;
|
||||
}}
|
||||
|
||||
switch (align) {{
|
||||
case 'start':
|
||||
if (shouldPositionAbove) {{
|
||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||
dropdown.style.transformOrigin = 'left bottom';
|
||||
}} else {{
|
||||
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
||||
dropdown.style.transformOrigin = 'left top';
|
||||
}}
|
||||
dropdown.style.left = `${{triggerRect.left}}px`;
|
||||
break;
|
||||
|
||||
case 'end':
|
||||
if (shouldPositionAbove) {{
|
||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||
dropdown.style.transformOrigin = 'right bottom';
|
||||
}} else {{
|
||||
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
||||
dropdown.style.transformOrigin = 'right top';
|
||||
}}
|
||||
dropdown.style.left = `${{triggerRect.right - dropdownRect.width}}px`;
|
||||
break;
|
||||
|
||||
case 'start-outer':
|
||||
if (shouldPositionAbove) {{
|
||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||
dropdown.style.transformOrigin = 'right bottom';
|
||||
}} else {{
|
||||
dropdown.style.top = `${{triggerRect.top}}px`;
|
||||
dropdown.style.transformOrigin = 'right top';
|
||||
}}
|
||||
dropdown.style.left = `${{triggerRect.left - dropdownRect.width - 16}}px`;
|
||||
break;
|
||||
|
||||
case 'end-outer':
|
||||
if (shouldPositionAbove) {{
|
||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||
dropdown.style.transformOrigin = 'left bottom';
|
||||
}} else {{
|
||||
dropdown.style.top = `${{triggerRect.top}}px`;
|
||||
dropdown.style.transformOrigin = 'left top';
|
||||
}}
|
||||
dropdown.style.left = `${{triggerRect.right + 8}}px`;
|
||||
break;
|
||||
|
||||
case 'center':
|
||||
if (shouldPositionAbove) {{
|
||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||
dropdown.style.transformOrigin = 'center bottom';
|
||||
}} else {{
|
||||
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
||||
dropdown.style.transformOrigin = 'center top';
|
||||
}}
|
||||
dropdown.style.left = `${{triggerRect.left}}px`;
|
||||
dropdown.style.minWidth = `${{triggerRect.width}}px`;
|
||||
break;
|
||||
}}
|
||||
}};
|
||||
|
||||
const openDropdown = () => {{
|
||||
isOpen = true;
|
||||
|
||||
// Set state to open first to remove scale transform for accurate measurements
|
||||
dropdown.setAttribute('data-state', 'open');
|
||||
|
||||
// Make dropdown invisible but rendered to measure true height
|
||||
dropdown.style.visibility = 'hidden';
|
||||
dropdown.style.pointerEvents = 'auto';
|
||||
|
||||
// Force reflow to ensure height is calculated
|
||||
dropdown.offsetHeight;
|
||||
|
||||
// Calculate position with accurate height
|
||||
updatePosition();
|
||||
|
||||
// Now make it visible
|
||||
dropdown.style.visibility = 'visible';
|
||||
|
||||
// Lock all scrollable elements
|
||||
window.ScrollLock.lock();
|
||||
|
||||
// Close on click outside
|
||||
setTimeout(() => {{
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}}, 0);
|
||||
}};
|
||||
|
||||
const closeDropdown = () => {{
|
||||
isOpen = false;
|
||||
dropdown.setAttribute('data-state', 'closed');
|
||||
dropdown.style.pointerEvents = 'none';
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
|
||||
// Unlock scroll after animation (200ms delay)
|
||||
window.ScrollLock.unlock(200);
|
||||
}};
|
||||
|
||||
const handleClickOutside = (e) => {{
|
||||
if (!dropdown.contains(e.target) && !trigger.contains(e.target)) {{
|
||||
closeDropdown();
|
||||
}}
|
||||
}};
|
||||
|
||||
// Toggle dropdown when trigger is clicked
|
||||
trigger.addEventListener('click', (e) => {{
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if any other dropdown is open
|
||||
const allDropdowns = document.querySelectorAll('[data-target=\"target__dropdown\"]');
|
||||
let otherDropdownOpen = false;
|
||||
allDropdowns.forEach(dd => {{
|
||||
if (dd !== dropdown && dd.getAttribute('data-state') === 'open') {{
|
||||
otherDropdownOpen = true;
|
||||
dd.setAttribute('data-state', 'closed');
|
||||
dd.style.pointerEvents = 'none';
|
||||
// Unlock scroll
|
||||
if (window.ScrollLock) {{
|
||||
window.ScrollLock.unlock(200);
|
||||
}}
|
||||
}}
|
||||
}});
|
||||
|
||||
// If another dropdown was open, just close it and don't open this one
|
||||
if (otherDropdownOpen) {{
|
||||
return;
|
||||
}}
|
||||
|
||||
// Normal toggle behavior
|
||||
if (isOpen) {{
|
||||
closeDropdown();
|
||||
}} else {{
|
||||
openDropdown();
|
||||
}}
|
||||
}});
|
||||
|
||||
// Close when action is clicked
|
||||
const actions = dropdown.querySelectorAll('[data-dropdown-close]');
|
||||
actions.forEach(action => {{
|
||||
action.addEventListener('click', () => {{
|
||||
closeDropdown();
|
||||
}});
|
||||
}});
|
||||
|
||||
// Handle ESC key to close
|
||||
document.addEventListener('keydown', (e) => {{
|
||||
if (e.key === 'Escape' && isOpen) {{
|
||||
e.preventDefault();
|
||||
closeDropdown();
|
||||
}}
|
||||
}});
|
||||
}};
|
||||
|
||||
if (document.readyState === 'loading') {{
|
||||
document.addEventListener('DOMContentLoaded', setupDropdown);
|
||||
}} else {{
|
||||
setupDropdown();
|
||||
}}
|
||||
}})();
|
||||
"#,
|
||||
target_id_for_script,
|
||||
target_id_for_script,
|
||||
)}
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DropdownMenuSub(children: Children) -> impl IntoView {
|
||||
// TODO. Find a better way for dropdown__menu_sub_trigger.
|
||||
clx! {DropdownMenuSubRoot, li, "dropdown__menu_sub_trigger", " relative inline-flex relative gap-2 items-center py-1.5 px-2 w-full text-sm no-underline rounded-sm transition-colors duration-200 cursor-pointer text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground"}
|
||||
|
||||
view! { <DropdownMenuSubRoot>{children()}</DropdownMenuSubRoot> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DropdownMenuSubTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!("flex items-center justify-between w-full", class);
|
||||
|
||||
view! {
|
||||
<span attr:data-name="DropdownMenuSubTrigger" class=class>
|
||||
<span class="flex gap-2 items-center">{children()}</span>
|
||||
<ChevronRight class="opacity-70 size-4" />
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DropdownMenuSubItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!(
|
||||
"inline-flex gap-2 items-center w-full rounded-sm px-3 py-2 text-sm transition-all duration-150 ease text-popover-foreground hover:bg-accent hover:text-accent-foreground cursor-pointer hover:translate-x-[2px]",
|
||||
class
|
||||
);
|
||||
|
||||
view! {
|
||||
<li data-name="DropdownMenuSubItem" class=class data-dropdown-close="true">
|
||||
{children()}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
35
frontend/src/components/ui/empty.rs
Normal file
35
frontend/src/components/ui/empty.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_ui::{clx, variants};
|
||||
|
||||
mod components {
|
||||
use super::*;
|
||||
clx! {Empty, div, "flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8 text-center"}
|
||||
clx! {EmptyHeader, div, "flex flex-col items-center gap-2"}
|
||||
clx! {EmptyTitle, h3, "text-lg font-semibold leading-none"}
|
||||
clx! {EmptyDescription, p, "text-muted-foreground text-sm"}
|
||||
clx! {EmptyContent, div, "flex items-center justify-center gap-2"}
|
||||
}
|
||||
|
||||
pub use components::*;
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
variants! {
|
||||
EmptyMedia {
|
||||
base: "flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
Default: "bg-transparent",
|
||||
Icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
size: {
|
||||
Default: "",
|
||||
}
|
||||
},
|
||||
component: {
|
||||
element: div
|
||||
}
|
||||
}
|
||||
}
|
||||
99
frontend/src/components/ui/input.rs
Normal file
99
frontend/src/components/ui/input.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
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")]
|
||||
#[allow(dead_code)]
|
||||
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(),
|
||||
}
|
||||
}
|
||||
17
frontend/src/components/ui/mod.rs
Normal file
17
frontend/src/components/ui/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
pub mod alert_dialog;
|
||||
pub mod button;
|
||||
pub mod card;
|
||||
pub mod checkbox;
|
||||
pub mod context_menu;
|
||||
pub mod data_table;
|
||||
pub mod dialog;
|
||||
pub mod dropdown_menu;
|
||||
pub mod empty;
|
||||
pub mod input;
|
||||
pub mod multi_select;
|
||||
pub mod select;
|
||||
pub mod separator;
|
||||
pub mod svg_icon;
|
||||
pub mod table;
|
||||
pub mod theme_toggle;
|
||||
pub mod toast;
|
||||
294
frontend/src/components/ui/multi_select.rs
Normal file
294
frontend/src/components/ui/multi_select.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use icons::{Check, ChevronDown, ChevronUp};
|
||||
use leptos::context::Provider;
|
||||
use leptos::prelude::*;
|
||||
use tw_merge::*;
|
||||
|
||||
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
|
||||
use crate::components::hooks::use_random::use_random_id_for;
|
||||
// * Reuse @select.rs
|
||||
pub use crate::components::ui::select::{
|
||||
SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem, SelectLabel as MultiSelectLabel,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum MultiSelectAlign {
|
||||
Start,
|
||||
#[default]
|
||||
Center,
|
||||
End,
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
#[component]
|
||||
pub fn MultiSelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
|
||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||
|
||||
view! {
|
||||
<span data-name="MultiSelectValue" class="text-sm text-muted-foreground truncate">
|
||||
{move || {
|
||||
let values = multi_select_ctx.values_signal.get();
|
||||
if values.is_empty() {
|
||||
placeholder.clone()
|
||||
} else {
|
||||
let count = values.len();
|
||||
if count == 1 { "1 selected".to_string() } else { format!("{} selected", count) }
|
||||
}
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MultiSelectOption(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional, into)] value: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||
|
||||
let value_clone = value.clone();
|
||||
let is_selected = Signal::derive(move || {
|
||||
if let Some(ref val) = value_clone {
|
||||
multi_select_ctx.values_signal.with(|values| values.contains(val))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
let class = tw_merge!(
|
||||
"group inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
class
|
||||
);
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
data-name="MultiSelectOption"
|
||||
class=class
|
||||
role="option"
|
||||
aria-selected=move || is_selected.get().to_string()
|
||||
on:click=move |ev: web_sys::MouseEvent| {
|
||||
ev.prevent_default();
|
||||
ev.stop_propagation();
|
||||
if let Some(val) = value.clone() {
|
||||
multi_select_ctx
|
||||
.values_signal
|
||||
.update(|values| {
|
||||
if values.contains(&val) {
|
||||
values.remove(&val);
|
||||
} else {
|
||||
values.insert(val);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
>
|
||||
{children()}
|
||||
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-selected:opacity-100" />
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MultiSelectContext {
|
||||
target_id: String,
|
||||
values_signal: RwSignal<HashSet<String>>,
|
||||
align: MultiSelectAlign,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MultiSelect(
|
||||
children: Children,
|
||||
#[prop(optional, into)] values: Option<RwSignal<HashSet<String>>>,
|
||||
#[prop(default = MultiSelectAlign::default())] align: MultiSelectAlign,
|
||||
) -> impl IntoView {
|
||||
let multi_select_target_id = use_random_id_for("multi_select");
|
||||
let values_signal = values.unwrap_or_else(|| RwSignal::new(HashSet::<String>::new()));
|
||||
|
||||
let multi_select_ctx = MultiSelectContext { target_id: multi_select_target_id.clone(), values_signal, align };
|
||||
|
||||
view! {
|
||||
<Provider value=multi_select_ctx>
|
||||
<div data-name="MultiSelect" class="relative w-fit">
|
||||
{children()}
|
||||
</div>
|
||||
</Provider>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MultiSelectTrigger(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional, into)] id: String,
|
||||
) -> impl IntoView {
|
||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||
|
||||
let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() };
|
||||
|
||||
let button_class = tw_merge!(
|
||||
"w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
|
||||
&peer_class,
|
||||
class
|
||||
);
|
||||
|
||||
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", multi_select_ctx.target_id) };
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
data-name="MultiSelectTrigger"
|
||||
class=button_class
|
||||
id=button_id
|
||||
tabindex="0"
|
||||
data-multi-select-trigger=multi_select_ctx.target_id
|
||||
>
|
||||
{children()}
|
||||
<ChevronDown class="text-muted-foreground" />
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MultiSelectContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||
|
||||
let align_str = match multi_select_ctx.align {
|
||||
MultiSelectAlign::Start => "start",
|
||||
MultiSelectAlign::Center => "center",
|
||||
MultiSelectAlign::End => "end",
|
||||
};
|
||||
|
||||
let class = tw_merge!(
|
||||
"w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[align=start]:left-0 data-[align=center]:left-1/2 data-[align=center]:-translate-x-1/2 data-[align=end]:right-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
class
|
||||
);
|
||||
|
||||
let target_id_for_script = multi_select_ctx.target_id.clone();
|
||||
let target_id_for_script_2 = multi_select_ctx.target_id.clone();
|
||||
|
||||
// Scroll indicator signals
|
||||
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
||||
|
||||
view! {
|
||||
<div
|
||||
data-name="MultiSelectContent"
|
||||
class=class
|
||||
id=multi_select_ctx.target_id
|
||||
data-target="target__multi_select"
|
||||
data-state="closed"
|
||||
data-align=align_str
|
||||
style="pointer-events: none;"
|
||||
on:scroll=move |ev| on_scroll.run(ev)
|
||||
>
|
||||
<div
|
||||
data-scroll-up="true"
|
||||
class=move || {
|
||||
let is_up: bool = can_scroll_up_signal.get();
|
||||
if is_up {
|
||||
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
}
|
||||
>
|
||||
<ChevronUp class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
{children()}
|
||||
<div
|
||||
data-scroll-down="true"
|
||||
class=move || {
|
||||
let is_down: bool = can_scroll_down_signal.get();
|
||||
if is_down {
|
||||
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
}
|
||||
>
|
||||
<ChevronDown class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{format!(
|
||||
r#"
|
||||
(function() {{
|
||||
const setupMultiSelect = () => {{
|
||||
const multiSelect = document.querySelector('#{}');
|
||||
const trigger = document.querySelector('[data-multi-select-trigger="{}"]');
|
||||
|
||||
if (!multiSelect || !trigger) {{
|
||||
setTimeout(setupMultiSelect, 50);
|
||||
return;
|
||||
}}
|
||||
|
||||
if (multiSelect.hasAttribute('data-initialized')) {{
|
||||
return;
|
||||
}}
|
||||
multiSelect.setAttribute('data-initialized', 'true');
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
const openMultiSelect = () => {{
|
||||
isOpen = true;
|
||||
if (window.ScrollLock) window.ScrollLock.lock();
|
||||
multiSelect.setAttribute('data-state', 'open');
|
||||
multiSelect.style.pointerEvents = 'auto';
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
multiSelect.style.minWidth = `${{triggerRect.width}}px`;
|
||||
multiSelect.dispatchEvent(new Event('scroll'));
|
||||
setTimeout(() => {{
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}}, 0);
|
||||
}};
|
||||
|
||||
const closeMultiSelect = () => {{
|
||||
isOpen = false;
|
||||
multiSelect.setAttribute('data-state', 'closed');
|
||||
multiSelect.style.pointerEvents = 'none';
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
if (window.ScrollLock) window.ScrollLock.unlock(200);
|
||||
}};
|
||||
|
||||
const handleClickOutside = (e) => {{
|
||||
if (!multiSelect.contains(e.target) && !trigger.contains(e.target)) {{
|
||||
closeMultiSelect();
|
||||
}}
|
||||
}};
|
||||
|
||||
trigger.addEventListener('click', (e) => {{
|
||||
e.stopPropagation();
|
||||
if (isOpen) closeMultiSelect(); else openMultiSelect();
|
||||
}});
|
||||
|
||||
document.addEventListener('keydown', (e) => {{
|
||||
if (e.key === 'Escape' && isOpen) {{
|
||||
e.preventDefault();
|
||||
closeMultiSelect();
|
||||
}}
|
||||
}});
|
||||
}};
|
||||
|
||||
if (document.readyState === 'loading') {{
|
||||
document.addEventListener('DOMContentLoaded', setupMultiSelect);
|
||||
}} else {{
|
||||
setupMultiSelect();
|
||||
}}
|
||||
}})();
|
||||
"#,
|
||||
target_id_for_script,
|
||||
target_id_for_script_2,
|
||||
)}
|
||||
</script>
|
||||
}.into_any()
|
||||
}
|
||||
311
frontend/src/components/ui/select.rs
Normal file
311
frontend/src/components/ui/select.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
use icons::{Check, ChevronDown, ChevronUp};
|
||||
use leptos::context::Provider;
|
||||
use leptos::prelude::*;
|
||||
use leptos_ui::clx;
|
||||
use strum::{AsRefStr, Display};
|
||||
use tw_merge::*;
|
||||
|
||||
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
|
||||
use crate::components::hooks::use_random::use_random_id_for;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, AsRefStr)]
|
||||
pub enum SelectPosition {
|
||||
#[default]
|
||||
Below,
|
||||
Above,
|
||||
}
|
||||
|
||||
mod components {
|
||||
use super::*;
|
||||
clx! {SelectLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
|
||||
clx! {SelectItem, li, "inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4"}
|
||||
}
|
||||
|
||||
pub use components::*;
|
||||
|
||||
#[component]
|
||||
pub fn SelectGroup(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = "Select options".into(), into)] aria_label: String,
|
||||
) -> impl IntoView {
|
||||
let merged_class = tw_merge!("group", class);
|
||||
|
||||
view! {
|
||||
<ul data-name="SelectGroup" role="listbox" aria-label=aria_label class=merged_class>
|
||||
{children()}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
|
||||
let select_ctx = expect_context::<SelectContext>();
|
||||
|
||||
view! {
|
||||
<span data-name="SelectValue" class="text-sm text-muted-foreground truncate">
|
||||
{move || { select_ctx.value_signal.get().unwrap_or_else(|| placeholder.clone()) }}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectOption(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = false.into(), into)] aria_selected: Signal<bool>,
|
||||
#[prop(optional, into)] value: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<SelectContext>();
|
||||
|
||||
let merged_class = tw_merge!(
|
||||
"group inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm cursor-pointer no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
class
|
||||
);
|
||||
|
||||
let value_for_check = value.clone();
|
||||
let is_selected = move || aria_selected.get() || ctx.value_signal.get() == value_for_check;
|
||||
|
||||
view! {
|
||||
<li
|
||||
data-name="SelectOption"
|
||||
class=merged_class
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected=move || is_selected().to_string()
|
||||
data-select-option="true"
|
||||
on:click=move |_| {
|
||||
let val = value.clone();
|
||||
ctx.value_signal.set(val.clone());
|
||||
if let Some(on_change) = ctx.on_change {
|
||||
on_change.run(val);
|
||||
}
|
||||
}
|
||||
>
|
||||
{children()}
|
||||
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-selected:opacity-100" />
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SelectContext {
|
||||
target_id: String,
|
||||
value_signal: RwSignal<Option<String>>,
|
||||
on_change: Option<Callback<Option<String>>>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Select(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional, into)] default_value: Option<String>,
|
||||
#[prop(optional)] on_change: Option<Callback<Option<String>>>,
|
||||
) -> impl IntoView {
|
||||
let select_target_id = use_random_id_for("select");
|
||||
let value_signal = RwSignal::new(default_value);
|
||||
|
||||
let ctx = SelectContext { target_id: select_target_id.clone(), value_signal, on_change };
|
||||
|
||||
let merged_class = tw_merge!("relative w-fit", class);
|
||||
|
||||
view! {
|
||||
<Provider value=ctx>
|
||||
<div data-name="Select" class=merged_class>
|
||||
{children()}
|
||||
</div>
|
||||
</Provider>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectTrigger(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional, into)] id: String,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<SelectContext>();
|
||||
|
||||
let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() };
|
||||
|
||||
let button_class = tw_merge!(
|
||||
"w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
|
||||
&peer_class,
|
||||
class
|
||||
);
|
||||
|
||||
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", ctx.target_id) };
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
data-name="SelectTrigger"
|
||||
class=button_class
|
||||
id=button_id
|
||||
tabindex="0"
|
||||
data-select-trigger=ctx.target_id
|
||||
>
|
||||
{children()}
|
||||
<ChevronDown class="text-muted-foreground" />
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectContent(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = SelectPosition::default())] position: SelectPosition,
|
||||
#[prop(optional)] on_close: Option<Callback<()>>,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<SelectContext>();
|
||||
|
||||
let merged_class = tw_merge!(
|
||||
"w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] left-0 data-[position=Above]:top-auto data-[position=Above]:bottom-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[state=closed]:data-[position=Below]:origin-top data-[state=open]:data-[position=Below]:origin-top data-[state=closed]:data-[position=Above]:origin-bottom data-[state=open]:data-[position=Above]:origin-bottom [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
class
|
||||
);
|
||||
|
||||
let target_id_for_script = ctx.target_id.clone();
|
||||
let target_id_for_script_2 = ctx.target_id.clone();
|
||||
|
||||
// Scroll indicator signals
|
||||
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
||||
|
||||
view! {
|
||||
<div
|
||||
data-name="SelectContent"
|
||||
class=merged_class
|
||||
on:selectclose=move |_: web_sys::CustomEvent| {
|
||||
if let Some(cb) = on_close {
|
||||
cb.run(());
|
||||
}
|
||||
}
|
||||
id=ctx.target_id
|
||||
data-target="target__select"
|
||||
data-state="closed"
|
||||
data-position=position.to_string()
|
||||
style="pointer-events: none;"
|
||||
on:scroll=move |ev| on_scroll.run(ev)
|
||||
>
|
||||
<div
|
||||
data-scroll-up="true"
|
||||
class=move || {
|
||||
let is_up: bool = can_scroll_up_signal.get();
|
||||
if is_up {
|
||||
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
}
|
||||
>
|
||||
<ChevronUp class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
{children()}
|
||||
<div
|
||||
data-scroll-down="true"
|
||||
class=move || {
|
||||
let is_down: bool = can_scroll_down_signal.get();
|
||||
if is_down {
|
||||
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
}
|
||||
>
|
||||
<ChevronDown class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{format!(
|
||||
r#"
|
||||
(function() {{
|
||||
const setupSelect = () => {{
|
||||
const select = document.querySelector('#{}');
|
||||
const trigger = document.querySelector('[data-select-trigger="{}"]');
|
||||
|
||||
if (!select || !trigger) {{
|
||||
setTimeout(setupSelect, 50);
|
||||
return;
|
||||
}}
|
||||
|
||||
if (select.hasAttribute('data-initialized')) {{
|
||||
return;
|
||||
}}
|
||||
select.setAttribute('data-initialized', 'true');
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
const updatePosition = () => {{
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom;
|
||||
const spaceAbove = triggerRect.top;
|
||||
|
||||
if (spaceBelow < 200 && spaceAbove > spaceBelow) {{
|
||||
select.setAttribute('data-position', 'Above');
|
||||
}} else {{
|
||||
select.setAttribute('data-position', 'Below');
|
||||
}}
|
||||
|
||||
select.style.minWidth = `${{triggerRect.width}}px`;
|
||||
}};
|
||||
|
||||
const openSelect = () => {{
|
||||
isOpen = true;
|
||||
if (window.ScrollLock) window.ScrollLock.lock();
|
||||
updatePosition();
|
||||
select.setAttribute('data-state', 'open');
|
||||
select.style.pointerEvents = 'auto';
|
||||
select.dispatchEvent(new Event('scroll'));
|
||||
setTimeout(() => {{
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}}, 0);
|
||||
}};
|
||||
|
||||
const closeSelect = () => {{
|
||||
isOpen = false;
|
||||
select.setAttribute('data-state', 'closed');
|
||||
select.style.pointerEvents = 'none';
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
select.dispatchEvent(new CustomEvent('selectclose', {{ bubbles: false }}));
|
||||
if (window.ScrollLock) window.ScrollLock.unlock(200);
|
||||
}};
|
||||
|
||||
const handleClickOutside = (e) => {{
|
||||
if (!select.contains(e.target) && !trigger.contains(e.target)) {{
|
||||
closeSelect();
|
||||
}}
|
||||
}};
|
||||
|
||||
trigger.addEventListener('click', (e) => {{
|
||||
e.stopPropagation();
|
||||
if (isOpen) closeSelect(); else openSelect();
|
||||
}});
|
||||
|
||||
const options = select.querySelectorAll('[data-select-option]');
|
||||
options.forEach(option => {{
|
||||
option.addEventListener('click', () => closeSelect());
|
||||
}});
|
||||
|
||||
document.addEventListener('keydown', (e) => {{
|
||||
if (e.key === 'Escape' && isOpen) {{
|
||||
e.preventDefault();
|
||||
closeSelect();
|
||||
}}
|
||||
}});
|
||||
}};
|
||||
|
||||
if (document.readyState === 'loading') {{
|
||||
document.addEventListener('DOMContentLoaded', setupSelect);
|
||||
}} else {{
|
||||
setupSelect();
|
||||
}}
|
||||
}})();
|
||||
"#,
|
||||
target_id_for_script,
|
||||
target_id_for_script_2,
|
||||
)}
|
||||
</script>
|
||||
}.into_any()
|
||||
}
|
||||
35
frontend/src/components/ui/separator.rs
Normal file
35
frontend/src/components/ui/separator.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use leptos::prelude::*;
|
||||
use tw_merge::*;
|
||||
|
||||
#[component]
|
||||
pub fn Separator(
|
||||
#[prop(into, optional)] orientation: Signal<SeparatorOrientation>,
|
||||
#[prop(into, optional)] class: String,
|
||||
// children: Children,
|
||||
) -> impl IntoView {
|
||||
let merged_class = Memo::new(move |_| {
|
||||
let orientation = orientation.get();
|
||||
let separator = SeparatorClass { orientation };
|
||||
separator.with_class(class.clone())
|
||||
});
|
||||
|
||||
view! { <div class=merged_class role="separator" /> }
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* 🧬 STRUCT 🧬 */
|
||||
/* ========================================================== */
|
||||
|
||||
#[derive(TwClass, Default)]
|
||||
#[tw(class = "shrink-0 bg-border")]
|
||||
pub struct SeparatorClass {
|
||||
orientation: SeparatorOrientation,
|
||||
}
|
||||
|
||||
#[derive(TwVariant)]
|
||||
pub enum SeparatorOrientation {
|
||||
#[tw(default, class = "w-full h-[1px]")]
|
||||
Default,
|
||||
#[tw(class = "h-full w-[1px]")]
|
||||
Vertical,
|
||||
}
|
||||
25
frontend/src/components/ui/svg_icon.rs
Normal file
25
frontend/src/components/ui/svg_icon.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use leptos::prelude::*;
|
||||
use tw_merge::tw_merge;
|
||||
|
||||
#[component]
|
||||
pub fn SvgIcon(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
) -> impl IntoView {
|
||||
let class = tw_merge!("size-4", class);
|
||||
|
||||
view! {
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class=class
|
||||
>
|
||||
{children()}
|
||||
</svg>
|
||||
}
|
||||
}
|
||||
56
frontend/src/components/ui/table.rs
Normal file
56
frontend/src/components/ui/table.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use leptos::prelude::*;
|
||||
use tw_merge::tw_merge;
|
||||
|
||||
#[component]
|
||||
pub fn TableWrapper(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!("overflow-hidden rounded-md border w-full", class);
|
||||
view! { <div class=class>{children()}</div> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Table(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!("w-full text-sm border-collapse", class);
|
||||
view! { <table class=class>{children()}</table> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TableCaption(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!("mt-4 text-sm text-muted-foreground", class);
|
||||
view! { <caption class=class>{children()}</caption> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TableHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!("[&_tr]:border-b bg-muted/50", class);
|
||||
view! { <thead class=class>{children()}</thead> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TableRow(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!("border-b transition-colors data-[state=selected]:bg-muted hover:bg-muted/50", class);
|
||||
view! { <tr class=class>{children()}</tr> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TableHead(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!("h-10 px-4 text-left align-middle font-medium text-muted-foreground whitespace-nowrap", class);
|
||||
view! { <th class=class>{children()}</th> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TableBody(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!("[&_tr:last-child]:border-0", class);
|
||||
view! { <tbody class=class>{children()}</tbody> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TableCell(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!("p-2 px-4 align-middle", class);
|
||||
view! { <td class=class>{children()}</td> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TableFooter(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", class);
|
||||
view! { <tfoot class=class>{children()}</tfoot> }
|
||||
}
|
||||
76
frontend/src/components/ui/theme_toggle.rs
Normal file
76
frontend/src/components/ui/theme_toggle.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use crate::components::ui::svg_icon::SvgIcon;
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::components::hooks::use_theme_mode::use_theme_mode;
|
||||
|
||||
#[component]
|
||||
pub fn ThemeToggle() -> impl IntoView {
|
||||
let theme_mode = use_theme_mode();
|
||||
|
||||
view! {
|
||||
<style>
|
||||
{"
|
||||
.theme__toggle_transition {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
svg path {
|
||||
transform-origin: center;
|
||||
transition: all .6s ease;
|
||||
transform: translate3d(0,0,0);
|
||||
backface-visibility: hidden;
|
||||
|
||||
&.sun {
|
||||
transform: scale(.4) rotate(60deg);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.moon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.switch {
|
||||
svg path {
|
||||
&.sun {
|
||||
transform: scale(1) rotate(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.moon {
|
||||
transform: scale(.4) rotate(-60deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"}
|
||||
</style>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle theme"
|
||||
class=move || {
|
||||
let base_class = "theme__toggle_transition";
|
||||
if theme_mode.get() { format!("{base_class} switch") } else { base_class.to_string() }
|
||||
}
|
||||
on:click=move |_| theme_mode.toggle()
|
||||
>
|
||||
<SvgIcon class="size-4">
|
||||
<path
|
||||
d="M12 1.75V3.25M12 20.75V22.25M1.75 12H3.25M20.75 12H22.25M4.75216 4.75216L5.81282 5.81282M18.1872 18.1872L19.2478 19.2478M4.75216 19.2478L5.81282 18.1872M18.1872 5.81282L19.2478 4.75216M16.25 12C16.25 14.3472 14.3472 16.25 12 16.25C9.65279 16.25 7.75 14.3472 7.75 12C7.75 9.65279 9.65279 7.75 12 7.75C14.3472 7.75 16.25 9.65279 16.25 12Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
class="sun text-neutral-300"
|
||||
/>
|
||||
<path
|
||||
d="M2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C16.7154 21.25 20.6068 17.7216 21.1778 13.161C20.1198 13.8498 18.8566 14.25 17.5 14.25C13.7721 14.25 10.75 11.2279 10.75 7.5C10.75 5.66012 11.4861 3.99217 12.6799 2.77461C12.4554 2.7583 12.2287 2.75 12 2.75C6.89137 2.75 2.75 6.89137 2.75 12Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
class="moon text-neutral-700"
|
||||
/>
|
||||
</SvgIcon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
245
frontend/src/components/ui/toast.rs
Normal file
245
frontend/src/components/ui/toast.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
use leptos::prelude::*;
|
||||
use tw_merge::*;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ToastType {
|
||||
#[default]
|
||||
Default,
|
||||
Success,
|
||||
Error,
|
||||
Warning,
|
||||
Info,
|
||||
Loading,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum SonnerPosition {
|
||||
TopLeft,
|
||||
TopCenter,
|
||||
TopRight,
|
||||
#[default]
|
||||
BottomRight,
|
||||
BottomCenter,
|
||||
BottomLeft,
|
||||
}
|
||||
|
||||
#[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(
|
||||
toast: ToastData,
|
||||
index: usize,
|
||||
total: usize,
|
||||
position: SonnerPosition,
|
||||
#[prop(optional)] on_dismiss: Option<Callback<()>>,
|
||||
) -> impl IntoView {
|
||||
let variant_classes = match toast.variant {
|
||||
ToastType::Default => "bg-background text-foreground border-border",
|
||||
ToastType::Success => "bg-background text-foreground border-border [&_.icon]:text-green-500",
|
||||
ToastType::Error => "bg-background text-foreground border-border [&_.icon]:text-destructive",
|
||||
ToastType::Warning => "bg-background text-foreground border-border [&_.icon]:text-yellow-500",
|
||||
ToastType::Info => "bg-background text-foreground border-border [&_.icon]:text-blue-500",
|
||||
ToastType::Loading => "bg-background text-foreground border-border",
|
||||
};
|
||||
|
||||
let bar_color = match toast.variant {
|
||||
ToastType::Success => "bg-green-500",
|
||||
ToastType::Error => "bg-destructive",
|
||||
ToastType::Warning => "bg-yellow-500",
|
||||
ToastType::Info => "bg-blue-500",
|
||||
_ => "bg-primary",
|
||||
};
|
||||
|
||||
// Sonner Stacking Logic
|
||||
let inverse_index = index;
|
||||
let offset = inverse_index as f64 * 12.0;
|
||||
let scale = 1.0 - (inverse_index as f64 * 0.05);
|
||||
let opacity = if inverse_index > 2 { 0.0 } else { 1.0 - (inverse_index as f64 * 0.15) };
|
||||
|
||||
let is_bottom = position.to_string().contains("Bottom");
|
||||
let y_direction = if is_bottom { -1.0 } else { 1.0 };
|
||||
let translate_y = offset * y_direction;
|
||||
|
||||
let style = format!(
|
||||
"z-index: {}; transform: translateY({}px) scale({}); opacity: {};",
|
||||
total - index,
|
||||
translate_y,
|
||||
scale,
|
||||
opacity
|
||||
);
|
||||
|
||||
let icon = match toast.variant {
|
||||
ToastType::Success => Some(view! { <span class="icon font-bold">"✓"</span> }.into_any()),
|
||||
ToastType::Error => Some(view! { <span class="icon font-bold">"✕"</span> }.into_any()),
|
||||
ToastType::Warning => Some(view! { <span class="icon font-bold">"⚠"</span> }.into_any()),
|
||||
ToastType::Info => Some(view! { <span class="icon font-bold">"ℹ"</span> }.into_any()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
view! {
|
||||
<style>
|
||||
"
|
||||
@keyframes sonner-progress {
|
||||
from { transform: scaleX(1); }
|
||||
to { transform: scaleX(0); }
|
||||
}
|
||||
.sonner-progress-bar {
|
||||
animation: sonner-progress linear forwards;
|
||||
transform-origin: left;
|
||||
}
|
||||
"
|
||||
</style>
|
||||
<div
|
||||
class=tw_merge!(
|
||||
"absolute transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto overflow-hidden",
|
||||
"flex items-center gap-3 w-full max-w-[calc(100vw-2rem)] sm:max-w-[380px] p-4 rounded-lg border shadow-lg bg-card",
|
||||
if is_bottom { "bottom-0" } else { "top-0" },
|
||||
variant_classes
|
||||
)
|
||||
style=style
|
||||
on:click=move |_| {
|
||||
if let Some(cb) = on_dismiss {
|
||||
cb.run(());
|
||||
}
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
<div class="flex flex-col gap-0.5 overflow-hidden flex-1">
|
||||
<div class="text-sm font-semibold truncate leading-tight">{toast.title}</div>
|
||||
{move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-70 truncate">{d.clone()}</div> })}
|
||||
</div>
|
||||
|
||||
// Progress Bar
|
||||
<div
|
||||
class=tw_merge!("absolute bottom-0 left-0 h-1 w-full sonner-progress-bar opacity-40", bar_color)
|
||||
style=format!("animation-duration: {}ms;", toast.duration)
|
||||
/>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static TOASTS: std::cell::RefCell<Option<RwSignal<Vec<ToastData>>>> = std::cell::RefCell::new(None);
|
||||
}
|
||||
|
||||
pub fn provide_toaster() {
|
||||
let toasts = RwSignal::new(Vec::<ToastData>::new());
|
||||
TOASTS.with(|t| *t.borrow_mut() = Some(toasts));
|
||||
provide_context(ToasterStore { toasts });
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
|
||||
let store = use_context::<ToasterStore>().expect("Toaster context not found");
|
||||
let toasts = store.toasts;
|
||||
let is_hovered = RwSignal::new(false);
|
||||
|
||||
let container_class = match position {
|
||||
SonnerPosition::TopLeft => "left-6 top-6 items-start",
|
||||
SonnerPosition::TopRight => ("right-6 top-6 items-end"),
|
||||
SonnerPosition::TopCenter => ("left-1/2 -translate-x-1/2 top-6 items-center"),
|
||||
SonnerPosition::BottomCenter => ("left-1/2 -translate-x-1/2 bottom-6 items-center"),
|
||||
SonnerPosition::BottomLeft => ("left-6 bottom-6 items-start"),
|
||||
SonnerPosition::BottomRight => ("right-6 bottom-6 items-end"),
|
||||
};
|
||||
|
||||
view! {
|
||||
<div
|
||||
class=tw_merge!(
|
||||
"fixed z-[100] flex flex-col pointer-events-none min-h-[100px] w-full sm:w-[400px]",
|
||||
container_class,
|
||||
"pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] px-4 sm:px-0"
|
||||
)
|
||||
on:mouseenter=move |_| is_hovered.set(true)
|
||||
on:mouseleave=move |_| is_hovered.set(false)
|
||||
>
|
||||
<For
|
||||
each=move || {
|
||||
let list = toasts.get();
|
||||
list.into_iter().rev().enumerate().collect::<Vec<_>>()
|
||||
}
|
||||
key=|(_, toast)| toast.id
|
||||
children=move |(index, toast)| {
|
||||
let id = toast.id;
|
||||
let total = toasts.with(|t| t.len());
|
||||
|
||||
let expanded_style = move || {
|
||||
if is_hovered.get() {
|
||||
let offset = index as f64 * 64.0;
|
||||
let is_bottom = position.to_string().contains("Bottom");
|
||||
let y_dir = if is_bottom { -1.0 } else { 1.0 };
|
||||
format!("transform: translateY({}px) scale(1); opacity: 1;", offset * y_dir)
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="contents" style=expanded_style>
|
||||
<SonnerTrigger
|
||||
toast=toast
|
||||
index=index
|
||||
total=total
|
||||
position=position
|
||||
on_dismiss=Callback::new(move |_| {
|
||||
toasts.update(|vec| vec.retain(|t| t.id != id));
|
||||
})
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
pub fn toast(title: impl Into<String>, variant: ToastType) {
|
||||
let signal_opt = TOASTS.with(|t| *t.borrow());
|
||||
|
||||
if let Some(toasts) = signal_opt {
|
||||
let id = js_sys::Math::random().to_bits();
|
||||
let new_toast = ToastData {
|
||||
id,
|
||||
title: title.into(),
|
||||
description: None,
|
||||
variant,
|
||||
duration: 4000,
|
||||
};
|
||||
|
||||
toasts.update(|t| {
|
||||
t.push(new_toast.clone());
|
||||
if t.len() > 5 {
|
||||
t.remove(0);
|
||||
}
|
||||
});
|
||||
|
||||
let duration = new_toast.duration;
|
||||
leptos::task::spawn_local(async move {
|
||||
gloo_timers::future::TimeoutFuture::new(duration as u32).await;
|
||||
toasts.update(|vec| vec.retain(|t| t.id != id));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn toast_success(title: impl Into<String>) { toast(title, ToastType::Success); }
|
||||
#[allow(dead_code)]
|
||||
pub fn toast_error(title: impl Into<String>) { toast(title, ToastType::Error); }
|
||||
#[allow(dead_code)]
|
||||
pub fn toast_warning(title: impl Into<String>) { toast(title, ToastType::Warning); }
|
||||
#[allow(dead_code)]
|
||||
pub fn toast_info(title: impl Into<String>) { toast(title, ToastType::Info); }
|
||||
@@ -2,19 +2,26 @@ use futures::StreamExt;
|
||||
use gloo_net::eventsource::futures::EventSource;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use shared::{AppEvent, GlobalStats, NotificationLevel, SystemNotification, Torrent};
|
||||
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
|
||||
use std::collections::HashMap;
|
||||
use struct_patch::traits::Patch;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
|
||||
use crate::components::ui::toast::{ToastType, toast};
|
||||
|
||||
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
|
||||
let msg = message.into();
|
||||
match level {
|
||||
NotificationLevel::Info => { leptos_shadcn_toast::toast::info(&msg).show(); },
|
||||
NotificationLevel::Success => { leptos_shadcn_toast::toast::success(&msg).show(); },
|
||||
NotificationLevel::Warning => { leptos_shadcn_toast::toast::warning(&msg).show(); },
|
||||
NotificationLevel::Error => { leptos_shadcn_toast::toast::error(&msg).show(); },
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::{Notification, NotificationOptions};
|
||||
use leptos::prelude::*;
|
||||
|
||||
|
||||
/// Request browser notification permission from user
|
||||
pub async fn request_notification_permission() -> bool {
|
||||
|
||||
@@ -26,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"] }
|
||||
struct-patch = "0.5"
|
||||
rmp-serde = "1.3"
|
||||
bytes = "1"
|
||||
http = "1"
|
||||
|
||||
# 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_axum = { version = "0.8.7", optional = true }
|
||||
axum = { version = "0.8", features = ["macros"], optional = true }
|
||||
|
||||
# SSR Dependencies (XML-RPC & SCGI)
|
||||
tokio = { version = "1", features = ["full"], optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
thiserror = { version = "2", optional = true }
|
||||
quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = true }
|
||||
|
||||
@@ -34,7 +35,6 @@ bcrypt = { version = "0.17", optional = true }
|
||||
default = []
|
||||
ssr = [
|
||||
"dep:tokio",
|
||||
"dep:bytes",
|
||||
"dep:thiserror",
|
||||
"dep:quick-xml",
|
||||
"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")]
|
||||
pub mod db;
|
||||
|
||||
pub mod codec;
|
||||
|
||||
pub mod server_fns;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use leptos::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::codec::MsgPack;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct UserResponse {
|
||||
@@ -19,7 +20,7 @@ pub struct SetupStatus {
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
#[server(GetSetupStatus, "/api/server_fns/GetSetupStatus")]
|
||||
#[server(GetSetupStatus, "/api/server_fns", input = MsgPack, output = MsgPack)]
|
||||
pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
|
||||
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", input = MsgPack, output = MsgPack)]
|
||||
pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> {
|
||||
use crate::DbContext;
|
||||
|
||||
@@ -54,7 +55,7 @@ pub async fn setup(username: String, password: String) -> Result<(), ServerFnErr
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server(Login, "/api/server_fns/Login")]
|
||||
#[server(Login, "/api/server_fns", input = MsgPack, output = MsgPack)]
|
||||
pub async fn login(username: String, password: String) -> Result<UserResponse, ServerFnError> {
|
||||
use crate::DbContext;
|
||||
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", input = MsgPack, output = MsgPack)]
|
||||
pub async fn logout() -> Result<(), ServerFnError> {
|
||||
use leptos_axum::ResponseOptions;
|
||||
use cookie::{Cookie, SameSite};
|
||||
@@ -131,7 +132,7 @@ pub async fn logout() -> Result<(), ServerFnError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server(GetUser, "/api/server_fns/GetUser")]
|
||||
#[server(GetUser, "/api/server_fns", input = MsgPack, output = MsgPack)]
|
||||
pub async fn get_user() -> Result<Option<UserResponse>, ServerFnError> {
|
||||
use axum::http::HeaderMap;
|
||||
use leptos_axum::extract;
|
||||
|
||||
BIN
vibetorrent.db-shm
Normal file
BIN
vibetorrent.db-shm
Normal file
Binary file not shown.
BIN
vibetorrent.db-wal
Normal file
BIN
vibetorrent.db-wal
Normal file
Binary file not shown.
Reference in New Issue
Block a user