Compare commits

..

18 Commits

Author SHA1 Message Date
spinline
f1c75c468a fix: restore table alignment by ensuring proper HTML structure and integrating multi-select DataTable
All checks were successful
Build MIPS Binary / build (push) Successful in 5m27s
2026-02-12 00:48:35 +03:00
spinline
bfb152f0d8 feat: fully implement official DataTable with multi-selection support
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 00:46:36 +03:00
spinline
8a7d9957aa fix: remove unused sonner module to resolve build errors
All checks were successful
Build MIPS Binary / build (push) Successful in 5m26s
2026-02-12 00:35:53 +03:00
spinline
56e8cc03d1 feat: implement official DataTable components and fix row spacing issues
Some checks failed
Build MIPS Binary / build (push) Failing after 1m33s
2026-02-12 00:32:58 +03:00
spinline
04cb7d51cb fix: resolve context menu positioning issue and integrate it into table rows
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 00:29:44 +03:00
spinline
555505b80e feat: implement real Sonner toast animations with stacking and hover expansion
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 00:25:57 +03:00
spinline
fa07fd88dc feat: modernize all buttons using the official rust-ui Button component
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 00:23:12 +03:00
spinline
bbb8e8dc98 fix: resolve compilation errors, fix lock_scroll path, and enhance DataTable attributes
All checks were successful
Build MIPS Binary / build (push) Successful in 5m27s
2026-02-12 00:15:17 +03:00
spinline
d09ecd21b7 feat: refactor torrent table to use DataTable components and fix context menu bugs
Some checks failed
Build MIPS Binary / build (push) Failing after 1m30s
2026-02-12 00:08:35 +03:00
spinline
9a00e341af feat: enhance context menu styles and add press-and-hold for destructive actions
Some checks failed
Build MIPS Binary / build (push) Failing after 1m31s
2026-02-12 00:03:32 +03:00
spinline
c78dcda55e feat: integrate ThemeToggle component and fix module visibility errors
All checks were successful
Build MIPS Binary / build (push) Successful in 5m27s
2026-02-11 23:54:36 +03:00
spinline
57abbb3335 feat: install and integrate official rust-ui context menu via ui-cli
Some checks failed
Build MIPS Binary / build (push) Failing after 1m31s
2026-02-11 23:45:07 +03:00
spinline
315a2421c4 feat: add animations and hover effects to toast notifications 2026-02-11 23:33:58 +03:00
spinline
c135c96d27 chore: silence unused code warnings in frontend UI components 2026-02-11 23:29:40 +03:00
spinline
315a2f9a53 fix(auth): fix 500 error and server function registration on Mac
All checks were successful
Build MIPS Binary / build (push) Successful in 5m13s
2026-02-11 23:22:38 +03:00
spinline
9d160a7ef5 fix(ui): use thread_local for toast signal to fix context issue
All checks were successful
Build MIPS Binary / build (push) Successful in 5m12s
2026-02-11 21:43:06 +03:00
spinline
a24e4101e8 feat(ui): toast entegrasyonu (sonner)
All checks were successful
Build MIPS Binary / build (push) Successful in 5m13s
2026-02-11 21:32:39 +03:00
spinline
7539307e18 feat: shadcn -> rust-ui.com migration + TorrentDetail silme
All checks were successful
Build MIPS Binary / build (push) Successful in 5m15s
- Tüm leptos-shadcn-* paketleri kaldırıldı (19 dependency)
- leptos_ui, tw_merge, strum eklendi
- components/ui/ modülü oluşturuldu (Button, Card, Input)
- TorrentDetail bileşeni tamamen silindi
- sidebar.rs: saf HTML+Tailwind ile SidebarButton yardımcı bileşeni
- toolbar.rs, login.rs, setup.rs, add_torrent.rs: saf HTML+Tailwind
- table.rs: shadcn Card -> rust-ui Card
- app.rs: Skeleton -> animate-pulse div
2026-02-11 21:01:51 +03:00
36 changed files with 2035 additions and 1094 deletions

433
Cargo.lock generated
View File

@@ -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"

View File

@@ -52,18 +52,22 @@ async fn auth_middleware(
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};
@@ -221,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);

View File

@@ -7,7 +7,7 @@ edition = "2021"
crate-type = ["cdylib", "rlib"]
[dependencies]
leptos = { version = "0.8.15", features = ["csr", "msgpack"] }
leptos = { version = "0.8.15", features = ["csr", "msgpack", "nightly"] }
leptos_router = { version = "0.8.11" }
console_error_panic_hook = "0.1"
@@ -35,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"
# Rust/UI Components
leptos_ui = "0.3"
tw_merge = "0.1"
strum = { version = "0.26", features = ["derive"] }
icons = { version = "0.18.0", features = ["leptos"] }
leptos-shadcn-dropdown-menu = "0.8"
leptos-shadcn-tooltip = "0.8"
leptos-shadcn-skeleton = "0.8"
[package.metadata.leptos]
tailwind-input-file = "input.css"

View File

@@ -13,7 +13,8 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
@@ -3737,6 +3738,15 @@
"node": ">=8.0"
}
},
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Wombosvideo"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",

View File

@@ -1,14 +1,14 @@
{
"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",
@@ -17,11 +17,13 @@
"postcss-preset-env": "^10.1.3",
"tailwindcss": "^4.1.18"
},
"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"
}
"keywords": [],
"license": "ISC",
"main": "tailwind.config.js",
"name": "frontend",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"type": "module",
"version": "1.0.0"
}

View 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();
})();

View File

@@ -1,27 +1,42 @@
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 crate::components::toast::Toaster;
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! {
<InnerApp />
<Toaster />
<InnerApp />
}
}
#[component]
fn InnerApp() -> impl IntoView {
crate::store::provide_torrent_store();
crate::components::toast::provide_toast_context();
let store = use_context::<crate::store::TorrentStore>();
let is_loading = signal(true);
@@ -131,36 +146,33 @@ fn InnerApp() -> impl IntoView {
<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="h-8 w-3/4 animate-pulse rounded-md bg-muted" />
<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 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">
// 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 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>
// 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 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>
// Status bar skeleton
<div class="border-t border-border p-3">
<Skeleton class="h-5 w-96" />
<div class="h-5 w-96 animate-pulse rounded-md bg-muted" />
</div>
</div>
</div>
@@ -171,7 +183,6 @@ fn InnerApp() -> impl IntoView {
<div class="flex-1 overflow-hidden">
<TorrentTable />
</div>
<TorrentDetail />
</div>
</Protected>
</Show>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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ı
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>
}
}

View File

@@ -0,0 +1,2 @@
pub mod use_random;
pub mod use_theme_mode;

View 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()
}

View 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();
}
}
}

View File

@@ -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>
}
}

View File

@@ -1,6 +1,4 @@
use leptos::prelude::*;
use leptos_shadcn_input::Input;
use leptos_shadcn_button::{Button, ButtonVariant, ButtonSize};
use crate::components::torrent::add_torrent::AddTorrentDialog;
#[component]
@@ -9,30 +7,36 @@ pub fn Toolbar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
let search_value = RwSignal::new(String::new());
// Sync search_value to store
Effect::new(move |_| {
let val = search_value.get();
store.search_query.set(val);
});
view! {
<div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);">
// Sol kısım: Menü butonu + Add Torrent
<div class="flex items-center gap-3">
// Mobile Menu Trigger
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon
class="lg:hidden"
on_click=Callback::new(move |()| is_mobile_menu_open.update(|v| *v = !*v))
<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))
<button
class="inline-flex items-center justify-center gap-2 h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all active:scale-[0.98]"
on:click=move |_| show_add_modal.1.set(true)
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 md:w-5 md:h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span class="hidden sm:inline">"Add Torrent"</span>
<span class="sm:hidden">"Add"</span>
</Button>
</button>
</div>
// Sağ kısım: Search kutusu
@@ -42,12 +46,11 @@ pub fn Toolbar() -> impl IntoView {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none">
<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"
<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"
class="file:text-foreground placeholder:text-muted-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 md:text-sm pl-8"
bind:value=search_value
/>
</div>
</div>

View File

@@ -1,5 +1,7 @@
pub mod hooks;
pub mod context_menu;
pub mod layout;
pub mod torrent;
pub mod auth;
pub mod toast;
// pub mod toast; (Removed)
pub mod ui;

View File

@@ -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 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>
}
}

View File

@@ -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>
}
}

View File

@@ -1,3 +1,2 @@
pub mod table;
pub mod add_torrent;
pub mod detail;

View File

@@ -1,10 +1,14 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use std::collections::HashSet;
use icons::{ArrowUpDown};
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;
fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
@@ -49,8 +53,11 @@ 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);
// Multi-selection state
let selected_hashes = RwSignal::new(HashSet::<String>::new());
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 +96,31 @@ 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 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,15 +134,6 @@ 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(),
}
} else { view! { <span class="ml-1 text-xs opacity-0 group-hover:opacity-50">""</span> }.into_any() }
};
let on_action = Callback::new(move |(action, hash): (String, String)| {
let (success_msg_str, error_msg_str): (&'static str, &'static str) = get_action_messages(&action);
let success_msg = success_msg_str.to_string();
@@ -132,50 +154,72 @@ pub fn TorrentTable() -> impl IntoView {
});
view! {
<div class="h-full bg-background relative flex flex-col overflow-hidden">
<div class="h-full bg-background relative flex flex-col overflow-hidden px-4 py-2">
// --- 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)}
<DataTableWrapper class="flex-1 min-h-0 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">
<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>
<DataTableHead class="cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
<div class="flex items-center gap-2">
"Name"
<ArrowUpDown class="size-3 opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
</DataTableHead>
<DataTableHead class="w-24">"Size"</DataTableHead>
<DataTableHead class="w-48">"Progress"</DataTableHead>
<DataTableHead class="w-24">"Status"</DataTableHead>
<DataTableHead class="w-24 text-right">"DL Speed"</DataTableHead>
<DataTableHead class="w-24 text-right">"UP Speed"</DataTableHead>
<DataTableHead class="w-24 text-right">"ETA"</DataTableHead>
<DataTableHead class="w-32 text-right">"Date"</DataTableHead>
</DataTableRow>
</DataTableHeader>
<DataTableBody>
<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
on_select=Callback::new(move |checked| {
selected_hashes.update(|selected| {
if checked { selected.insert(h_for_change.clone()); }
else { selected.remove(&h_for_change); }
});
})
/>
}
}
} />
</DataTableBody>
</DataTable>
</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)}
</DataTableWrapper>
// Selection Info Footer
<div class="flex items-center justify-between py-2 text-xs text-muted-foreground">
<div>
{move || format!("{} / {} torrent seçili", selected_count.get(), filtered_hashes.get().len())}
</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>
// Regular List
<div class="flex-1 overflow-y-auto min-h-0">
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
let on_action = on_action.clone();
move |hash| {
let h = hash.clone();
view! {
<TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
<TorrentRow hash=hash.clone() />
</TorrentContextMenu>
}
}
} />
</div>
</div>
@@ -185,12 +229,9 @@ pub fn TorrentTable() -> impl IntoView {
<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>
<TorrentCard hash=hash.clone() on_action=on_action.clone() />
</div>
}
}
@@ -198,12 +239,15 @@ pub fn TorrentTable() -> impl IntoView {
</div>
</div>
</div>
}
}.into_any()
}
#[component]
fn TorrentRow(
hash: String,
on_action: Callback<(String, String)>,
is_selected: Signal<bool>,
on_select: Callback<bool>,
) -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let h = hash.clone();
@@ -214,50 +258,76 @@ 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_for_title = t_name.clone();
let t_name_for_content = 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>
<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">
<Checkbox
checked=is_selected
on_checked_change=on_select
/>
</DataTableCell>
<DataTableCell class="font-medium truncate max-w-[200px] lg:max-w-md" attr:title=t_name_for_title>
{t_name_for_content}
</DataTableCell>
<DataTableCell class="font-mono text-xs text-muted-foreground">
{format_bytes(t.size)}
</DataTableCell>
<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: {}%", t.percent_complete)></div>
</div>
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>
</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>
}
</DataTableCell>
<DataTableCell class={format!("text-xs font-semibold {}", status_color)}>
{format!("{:?}", t.status)}
</DataTableCell>
<DataTableCell class="text-right font-mono text-xs text-green-600 dark:text-green-500 whitespace-nowrap">
{format_speed(t.down_rate)}
</DataTableCell>
<DataTableCell class="text-right font-mono text-xs text-blue-600 dark:text-blue-500 whitespace-nowrap">
{format_speed(t.up_rate)}
</DataTableCell>
<DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
{format_duration(t.eta)}
</DataTableCell>
<DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
{format_date(t.added_date)}
</DataTableCell>
</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 +338,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()
}

View 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
}
}
}

View 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::*;

View 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>
}
}

View File

@@ -0,0 +1,436 @@
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! {
<script src="/lock_scroll.js"></script>
<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>
}
}

View 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,
};

View 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(),
}
}

View File

@@ -0,0 +1,9 @@
pub mod button;
pub mod card;
pub mod input;
pub mod toast;
pub mod context_menu;
pub mod theme_toggle;
pub mod svg_icon;
pub mod table;
pub mod data_table;pub mod checkbox;

View 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>
}
}

View 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> }
}

View 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>
}
}

View File

@@ -0,0 +1,221 @@
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-success",
ToastType::Error => "bg-background text-foreground border-border [&_.icon]:text-destructive",
ToastType::Warning => "bg-background text-foreground border-border [&_.icon]:text-warning",
ToastType::Info => "bg-background text-foreground border-border [&_.icon]:text-info",
ToastType::Loading => "bg-background text-foreground border-border",
};
// Sonner Stacking Logic
// We calculate inverse index: 0 is the newest (top), 1 is older, etc.
let inverse_index = index;
let offset = inverse_index as f64 * 16.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.2) };
let is_bottom = !position.to_string().contains("Top");
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 text-success">""</span> }.into_any()),
ToastType::Error => Some(view! { <span class="icon text-destructive">""</span> }.into_any()),
ToastType::Warning => Some(view! { <span class="icon text-warning">""</span> }.into_any()),
ToastType::Info => Some(view! { <span class="icon text-info">""</span> }.into_any()),
_ => None,
};
view! {
<div
class=tw_merge!(
"absolute transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto",
"flex items-center gap-3 min-w-[350px] 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-1">
<div class="text-sm font-semibold">{toast.title}</div>
{move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-70">{d.clone()}</div> })}
</div>
</div>
}.into_any()
}
// Thread local storage for global access
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-[200px] w-[400px]", container_class)
on:mouseenter=move |_| is_hovered.set(true)
on:mouseleave=move |_| is_hovered.set(false)
>
<For
each=move || {
let list = toasts.get();
// Reverse the list so newest is at the end (for stacking)
// or newest is at the beginning (for display logic)
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());
// If hovered, expand the stack
let expanded_style = move || {
if is_hovered.get() {
let offset = index as f64 * 70.0;
let is_bottom = !position.to_string().contains("Top");
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 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()
}
// Global Helper Functions
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); }

View File

@@ -7,19 +7,21 @@ use std::collections::HashMap;
use struct_patch::traits::Patch;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use crate::components::toast::ToastContext;
use crate::components::ui::toast::{ToastType, toast};
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
let msg = message.into();
gloo_console::log!("TOAST CALL:", &msg, format!("{:?}", level));
log::info!("Displaying toast: [{:?}] {}", level, msg);
if let Some(context) = use_context::<ToastContext>() {
context.add(msg, level);
} else {
log::error!("ToastContext not found!");
gloo_console::error!("ToastContext not found!");
}
let variant = match level {
NotificationLevel::Success => ToastType::Success,
NotificationLevel::Error => ToastType::Error,
NotificationLevel::Warning => ToastType::Warning,
NotificationLevel::Info => ToastType::Info,
};
toast(msg, variant);
}

2
frontend/ui_config.toml Normal file
View File

@@ -0,0 +1,2 @@
base_color = "neutral"
base_path_components = "src/components"

View File

@@ -20,7 +20,7 @@ pub struct SetupStatus {
pub completed: bool,
}
#[server(GetSetupStatus, "/api/server_fns/GetSetupStatus", input = MsgPack, output = MsgPack)]
#[server(GetSetupStatus, "/api/server_fns", input = MsgPack, output = MsgPack)]
pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
use crate::DbContext;
@@ -33,7 +33,7 @@ pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
})
}
#[server(Setup, "/api/server_fns/Setup", input = MsgPack, output = MsgPack)]
#[server(Setup, "/api/server_fns", input = MsgPack, output = MsgPack)]
pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> {
use crate::DbContext;
@@ -55,7 +55,7 @@ pub async fn setup(username: String, password: String) -> Result<(), ServerFnErr
Ok(())
}
#[server(Login, "/api/server_fns/Login", input = MsgPack, output = MsgPack)]
#[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;
@@ -111,7 +111,7 @@ pub async fn login(username: String, password: String) -> Result<UserResponse, S
}
}
#[server(Logout, "/api/server_fns/Logout", input = MsgPack, output = MsgPack)]
#[server(Logout, "/api/server_fns", input = MsgPack, output = MsgPack)]
pub async fn logout() -> Result<(), ServerFnError> {
use leptos_axum::ResponseOptions;
use cookie::{Cookie, SameSite};
@@ -132,7 +132,7 @@ pub async fn logout() -> Result<(), ServerFnError> {
Ok(())
}
#[server(GetUser, "/api/server_fns/GetUser", input = MsgPack, output = MsgPack)]
#[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

Binary file not shown.

BIN
vibetorrent.db-wal Normal file

Binary file not shown.