Compare commits
4 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e9a042444 | ||
|
|
09a4c69282 | ||
|
|
a877e0c393 | ||
|
|
fd65df2962 |
@@ -22,32 +22,12 @@ jobs:
|
|||||||
git fetch --depth=1 origin ${{ gitea.sha }}
|
git fetch --depth=1 origin ${{ gitea.sha }}
|
||||||
git checkout FETCH_HEAD
|
git checkout FETCH_HEAD
|
||||||
|
|
||||||
- name: Cache Cargo
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/bin/
|
|
||||||
~/.cargo/registry/index/
|
|
||||||
~/.cargo/registry/cache/
|
|
||||||
~/.cargo/git/db/
|
|
||||||
target/
|
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-
|
|
||||||
|
|
||||||
- name: Cache Node Modules
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: frontend/node_modules
|
|
||||||
key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-node-
|
|
||||||
|
|
||||||
- name: Build Frontend
|
- name: Build Frontend
|
||||||
run: |
|
run: |
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npx @tailwindcss/cli -i input.css -o public/tailwind.css --minify --content './src/**/*.rs'
|
npx @tailwindcss/cli -i input.css -o public/tailwind.css --minify --content './src/**/*.rs'
|
||||||
|
# Trunk'ın optimizasyonunu kapalı (0) tutuyoruz çünkü Cargo.toml'daki opt-level='z' zaten o işi yapıyor.
|
||||||
trunk build --release
|
trunk build --release
|
||||||
|
|
||||||
- name: Build Backend (MIPS)
|
- name: Build Backend (MIPS)
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,3 +8,9 @@ backend.log
|
|||||||
.runner
|
.runner
|
||||||
.env
|
.env
|
||||||
backend/.env
|
backend/.env
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
|||||||
237
Cargo.lock
generated
237
Cargo.lock
generated
@@ -320,11 +320,10 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
"governor",
|
"governor",
|
||||||
"icons",
|
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"leptos",
|
"leptos",
|
||||||
"leptos_axum",
|
"leptos_axum",
|
||||||
"leptos_ui",
|
"leptos_router",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"openssl",
|
"openssl",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
@@ -345,7 +344,6 @@ dependencies = [
|
|||||||
"tower_governor",
|
"tower_governor",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tw_merge",
|
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"utoipa-swagger-ui",
|
"utoipa-swagger-ui",
|
||||||
"web-push",
|
"web-push",
|
||||||
@@ -523,9 +521,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.57"
|
version = "4.5.58"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a"
|
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -533,9 +531,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.57"
|
version = "4.5.58"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238"
|
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -557,9 +555,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_lex"
|
name = "clap_lex"
|
||||||
version = "0.7.7"
|
version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coarsetime"
|
name = "coarsetime"
|
||||||
@@ -943,9 +941,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.5"
|
version = "0.5.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
|
checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -1262,7 +1260,6 @@ dependencies = [
|
|||||||
"gloo-console",
|
"gloo-console",
|
||||||
"gloo-net",
|
"gloo-net",
|
||||||
"gloo-timers",
|
"gloo-timers",
|
||||||
"icons",
|
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"leptos",
|
"leptos",
|
||||||
"leptos-use",
|
"leptos-use",
|
||||||
@@ -1275,7 +1272,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"shared",
|
"shared",
|
||||||
"struct-patch",
|
"struct-patch",
|
||||||
"strum 0.26.3",
|
"strum",
|
||||||
"tailwind_fuse",
|
"tailwind_fuse",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tw_merge",
|
"tw_merge",
|
||||||
@@ -1430,6 +1427,19 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
"wasip2",
|
||||||
|
"wasip3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gloo-console"
|
name = "gloo-console"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -1638,9 +1648,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hmac-sha512"
|
name = "hmac-sha512"
|
||||||
version = "1.1.10"
|
version = "1.1.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "425aae4cbcd7250fdcc9665de9cbec21348dd4b6c6a7321b2f5eaf41a3e97dcd"
|
checksum = "5075d41b75a022af043a5bbf49b89abf17665d5aebf6f6ec64ffff207d87654d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
@@ -1868,17 +1878,6 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "icons"
|
|
||||||
version = "0.18.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "75da3d473e24e0b541bf28cf31e67b262c379a4cbc2149f4865b7d99406711dc"
|
|
||||||
dependencies = [
|
|
||||||
"leptos",
|
|
||||||
"strum 0.27.2",
|
|
||||||
"tw_merge",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -1960,6 +1959,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "id-arena"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -2128,6 +2133,12 @@ dependencies = [
|
|||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "leb128fmt"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leptos"
|
name = "leptos"
|
||||||
version = "0.8.15"
|
version = "0.8.15"
|
||||||
@@ -2388,9 +2399,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.180"
|
version = "0.2.181"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libm"
|
name = "libm"
|
||||||
@@ -2406,7 +2417,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"libc",
|
"libc",
|
||||||
"redox_syscall 0.7.0",
|
"redox_syscall 0.7.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3265,9 +3276,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.7.0"
|
version = "0.7.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27"
|
checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
@@ -3772,10 +3783,12 @@ dependencies = [
|
|||||||
"quick-xml",
|
"quick-xml",
|
||||||
"rmp-serde",
|
"rmp-serde",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"struct-patch",
|
"struct-patch",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4152,16 +4165,7 @@ version = "0.26.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"strum_macros 0.26.4",
|
"strum_macros",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "strum"
|
|
||||||
version = "0.27.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
|
||||||
dependencies = [
|
|
||||||
"strum_macros 0.27.2",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4177,18 +4181,6 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "strum_macros"
|
|
||||||
version = "0.27.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
|
||||||
dependencies = [
|
|
||||||
"heck",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.114",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -4303,12 +4295,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.24.0"
|
version = "3.25.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
|
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -4505,9 +4497,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.9.11+spec-1.1.0"
|
version = "0.9.12+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
|
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_spanned",
|
"serde_spanned",
|
||||||
@@ -4527,9 +4519,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
name = "toml_parser"
|
||||||
version = "1.0.6+spec-1.1.0"
|
version = "1.0.7+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
|
checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
@@ -5001,6 +4993,15 @@ dependencies = [
|
|||||||
"wit-bindgen",
|
"wit-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip3"
|
||||||
|
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasite"
|
name = "wasite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -5075,6 +5076,28 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-encoder"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||||
|
dependencies = [
|
||||||
|
"leb128fmt",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-metadata"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"indexmap",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -5110,6 +5133,18 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasmparser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
"indexmap",
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-push"
|
name = "web-push"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -5488,6 +5523,88 @@ name = "wit-bindgen"
|
|||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen-rust-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-core"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"indexmap",
|
||||||
|
"prettyplease",
|
||||||
|
"syn 2.0.114",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-component",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust-macro"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"prettyplease",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-bindgen-rust",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-component"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wasmparser",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-parser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"id-arena",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"unicode-xid",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
members = ["backend", "frontend", "shared"]
|
members = ["backend", "frontend", "shared"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[[workspace.metadata.leptos]]
|
|
||||||
tailwind-input-file = "frontend/input.css"
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
# En küçük binary boyutu
|
# En küçük binary boyutu
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["swagger"] # push-notifications kaldırıldı
|
default = ["swagger"]
|
||||||
push-notifications = ["web-push", "openssl"]
|
push-notifications = ["web-push", "openssl"]
|
||||||
swagger = ["utoipa-swagger-ui"]
|
swagger = ["utoipa-swagger-ui"]
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ clap = { version = "4.4", features = ["derive", "env"] }
|
|||||||
rust-embed = "8.2"
|
rust-embed = "8.2"
|
||||||
mime_guess = "2.0"
|
mime_guess = "2.0"
|
||||||
shared = { path = "../shared", features = ["ssr"] }
|
shared = { path = "../shared", features = ["ssr"] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||||
utoipa-swagger-ui = { version = "9.0", features = ["axum"], optional = true }
|
utoipa-swagger-ui = { version = "9.0", features = ["axum"], optional = true }
|
||||||
@@ -38,15 +38,13 @@ openssl = { version = "0.10", features = ["vendored"], optional = true }
|
|||||||
bcrypt = "0.17.0"
|
bcrypt = "0.17.0"
|
||||||
axum-extra = { version = "0.10", features = ["cookie"] }
|
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
anyhow = "1.0.101"
|
anyhow = "1.0"
|
||||||
time = { version = "0.3.47", features = ["serde", "formatting", "parsing"] }
|
time = { version = "0.3", features = ["serde", "formatting", "parsing"] }
|
||||||
tower_governor = "0.8.0"
|
tower_governor = "0.8.0"
|
||||||
governor = "0.10.4"
|
governor = "0.10.4"
|
||||||
|
|
||||||
# Leptos
|
# Leptos
|
||||||
leptos = { version = "0.8.15", features = ["nightly"] }
|
leptos = { version = "0.8.15", features = ["nightly", "msgpack"] }
|
||||||
leptos_axum = { version = "0.8.7" }
|
leptos_axum = { version = "0.8.7" }
|
||||||
jsonwebtoken = "9"
|
leptos_router = { version = "0.8.11" }
|
||||||
tw_merge = { version = "0.1.17", features = ["variant"] }
|
jsonwebtoken = "9"
|
||||||
icons = { version = "0.18.0", features = ["leptos"] }
|
|
||||||
leptos_ui = "0.3.20"
|
|
||||||
@@ -7,7 +7,6 @@ use rust_embed::RustEmbed;
|
|||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
pub mod notifications;
|
|
||||||
|
|
||||||
#[derive(RustEmbed)]
|
#[derive(RustEmbed)]
|
||||||
#[folder = "../frontend/dist"]
|
#[folder = "../frontend/dist"]
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
extract::{State, Query},
|
|
||||||
http::StatusCode,
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use shared::{AppEvent, SystemNotification, NotificationLevel};
|
|
||||||
use crate::AppState;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct TorrentFinishedQuery {
|
|
||||||
pub name: String,
|
|
||||||
pub hash: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn torrent_finished_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Query(params): Query<TorrentFinishedQuery>,
|
|
||||||
) -> StatusCode {
|
|
||||||
tracing::info!("WEBHOOK: Received notification from rTorrent. Name: {:?}, Hash: {:?}", params.name, params.hash);
|
|
||||||
|
|
||||||
let torrent_name = if params.name.is_empty() || params.name == "$d.name=" {
|
|
||||||
"Bilinmeyen Torrent".to_string()
|
|
||||||
} else {
|
|
||||||
params.name.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let message = format!("Torrent tamamlandı: {}", torrent_name);
|
|
||||||
|
|
||||||
// 1. Send to active SSE clients (for Toast)
|
|
||||||
let notification = SystemNotification {
|
|
||||||
level: NotificationLevel::Success,
|
|
||||||
message: message.clone(),
|
|
||||||
};
|
|
||||||
let _ = state.event_bus.send(AppEvent::Notification(notification));
|
|
||||||
|
|
||||||
// 2. Send Web Push Notification (for Background)
|
|
||||||
#[cfg(feature = "push-notifications")]
|
|
||||||
{
|
|
||||||
let push_store = state.push_store.clone();
|
|
||||||
let title = "Torrent Tamamlandı".to_string();
|
|
||||||
let body = message;
|
|
||||||
let name_for_log = torrent_name.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
tracing::info!("Attempting to send Web Push notification for torrent: {}", name_for_log);
|
|
||||||
match crate::push::send_push_notification(&push_store, &title, &body).await {
|
|
||||||
Ok(_) => tracing::info!("Web Push notification task completed for: {}", name_for_log),
|
|
||||||
Err(e) => tracing::error!("Failed to send Web Push notification for {}: {:?}", name_for_log, e),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusCode::OK
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,7 @@ use std::time::Duration;
|
|||||||
use tokio::sync::{broadcast, watch};
|
use tokio::sync::{broadcast, watch};
|
||||||
use tower::ServiceBuilder;
|
use tower::ServiceBuilder;
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
compression::{CompressionLayer, CompressionLevel},
|
compression::CompressionLayer,
|
||||||
cors::CorsLayer,
|
cors::CorsLayer,
|
||||||
trace::TraceLayer,
|
trace::TraceLayer,
|
||||||
};
|
};
|
||||||
@@ -52,23 +52,18 @@ async fn auth_middleware(
|
|||||||
request: Request<Body>,
|
request: Request<Body>,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
// Skip auth for public server functions
|
// Skip auth for public paths
|
||||||
let path = request.uri().path();
|
let path = request.uri().path();
|
||||||
if path.starts_with("/api/server_fns/Login")
|
if path.starts_with("/api/server_fns/Login") // Login server fn
|
||||||
|| path.starts_with("/api/server_fns/login")
|
|
||||||
|| path.starts_with("/api/server_fns/GetSetupStatus")
|
|| 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("/api/server_fns/setup")
|
|
||||||
|| path.starts_with("/api/internal/")
|
|
||||||
|| path.starts_with("/swagger-ui")
|
|| path.starts_with("/swagger-ui")
|
||||||
|| path.starts_with("/api-docs")
|
|| path.starts_with("/api-docs")
|
||||||
|| !path.starts_with("/api/")
|
|| !path.starts_with("/api/") // Allow static files (frontend)
|
||||||
{
|
{
|
||||||
return Ok(next.run(request).await);
|
return Ok(next.run(request).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Check token
|
// Check token
|
||||||
if let Some(token) = jar.get("auth_token") {
|
if let Some(token) = jar.get("auth_token") {
|
||||||
use jsonwebtoken::{decode, Validation, DecodingKey};
|
use jsonwebtoken::{decode, Validation, DecodingKey};
|
||||||
@@ -226,18 +221,6 @@ async fn main() {
|
|||||||
tracing::info!("Socket: {}", args.socket);
|
tracing::info!("Socket: {}", args.socket);
|
||||||
tracing::info!("Port: {}", args.port);
|
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 ...
|
// ... rest of the main function ...
|
||||||
// Startup Health Check
|
// Startup Health Check
|
||||||
let socket_path = std::path::Path::new(&args.socket);
|
let socket_path = std::path::Path::new(&args.socket);
|
||||||
@@ -314,7 +297,7 @@ async fn main() {
|
|||||||
let loop_interval = if active_clients > 0 {
|
let loop_interval = if active_clients > 0 {
|
||||||
Duration::from_secs(1)
|
Duration::from_secs(1)
|
||||||
} else {
|
} else {
|
||||||
Duration::from_secs(60)
|
Duration::from_secs(30)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Fetch Torrents
|
// 1. Fetch Torrents
|
||||||
@@ -424,38 +407,37 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async fn server_fn_handler(
|
||||||
|
axum::extract::State(state): axum::extract::State<AppState>,
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> impl axum::response::IntoResponse {
|
||||||
|
let scgi_path = state.scgi_socket_path.clone();
|
||||||
|
let db = state.db.clone();
|
||||||
|
|
||||||
|
leptos_axum::handle_server_fns_with_context(
|
||||||
|
move || {
|
||||||
|
leptos::context::provide_context(shared::ServerContext {
|
||||||
|
scgi_socket_path: scgi_path.clone(),
|
||||||
|
});
|
||||||
|
leptos::context::provide_context(shared::DbContext {
|
||||||
|
db: db.clone(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
|
||||||
let app = Router::new();
|
let app = Router::new();
|
||||||
|
|
||||||
#[cfg(feature = "swagger")]
|
#[cfg(feature = "swagger")]
|
||||||
let app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));
|
let app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));
|
||||||
|
|
||||||
// Setup & Auth Routes (cookie-based, stay as REST)
|
// Setup & Auth Routes (cookie-based, stay as REST)
|
||||||
// Setup & Auth Routes (cookie-based, stay as REST)
|
|
||||||
let scgi_path_for_ctx = args.socket.clone();
|
|
||||||
let db_for_ctx = db.clone();
|
|
||||||
let app = app
|
let app = app
|
||||||
.route("/api/events", get(sse::sse_handler))
|
.route("/api/events", get(sse::sse_handler))
|
||||||
.route("/api/internal/torrent-finished", post(handlers::notifications::torrent_finished_handler))
|
.route("/api/server_fns/{*fn_name}", post(server_fn_handler))
|
||||||
.route("/api/server_fns/{*fn_name}", post({
|
.fallback(handlers::static_handler)
|
||||||
let scgi_path = scgi_path_for_ctx.clone();
|
.with_state(app_state.clone());
|
||||||
let db = db_for_ctx.clone();
|
|
||||||
move |req: Request<Body>| {
|
|
||||||
let scgi_path = scgi_path.clone();
|
|
||||||
let db = db.clone();
|
|
||||||
leptos_axum::handle_server_fns_with_context(
|
|
||||||
move || {
|
|
||||||
leptos::context::provide_context(shared::ServerContext {
|
|
||||||
scgi_socket_path: scgi_path.clone(),
|
|
||||||
});
|
|
||||||
leptos::context::provide_context(shared::DbContext {
|
|
||||||
db: db.clone(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
req,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.fallback(handlers::static_handler);
|
|
||||||
|
|
||||||
let app = app
|
let app = app
|
||||||
.layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware))
|
.layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware))
|
||||||
@@ -463,8 +445,7 @@ async fn main() {
|
|||||||
.layer(
|
.layer(
|
||||||
CompressionLayer::new()
|
CompressionLayer::new()
|
||||||
.br(false)
|
.br(false)
|
||||||
.gzip(true)
|
.gzip(true),
|
||||||
.quality(CompressionLevel::Fastest),
|
|
||||||
)
|
)
|
||||||
.layer(
|
.layer(
|
||||||
ServiceBuilder::new()
|
ServiceBuilder::new()
|
||||||
|
|||||||
@@ -191,21 +191,11 @@ pub async fn send_push_notification(
|
|||||||
tracing::debug!("Push notification sent to: {}", subscription.endpoint);
|
tracing::debug!("Push notification sent to: {}", subscription.endpoint);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let err_msg = format!("{:?}", e);
|
tracing::error!("Failed to send push notification to {}: {}", subscription.endpoint, e);
|
||||||
tracing::error!("Delivery failed for {}: {}", subscription.endpoint, err_msg);
|
|
||||||
// Always remove on delivery failure (Gone, Unauthorized, etc.)
|
|
||||||
tracing::info!("Removing problematic subscription after delivery failure: {}", subscription.endpoint);
|
|
||||||
let _ = store.remove_subscription(&subscription.endpoint).await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => tracing::error!("Failed to build push message: {}", e),
|
||||||
let err_msg = format!("{:?}", e);
|
|
||||||
tracing::error!("Encryption/Build failed for {}: {}", subscription.endpoint, err_msg);
|
|
||||||
// Always remove on encryption failure
|
|
||||||
tracing::info!("Removing problematic subscription after encryption failure: {}", subscription.endpoint);
|
|
||||||
let _ = store.remove_subscription(&subscription.endpoint).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => tracing::error!("Failed to build VAPID signature: {}", e),
|
Err(e) => tracing::error!("Failed to build VAPID signature: {}", e),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ edition = "2021"
|
|||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
leptos = { version = "0.8.15", features = ["csr", "msgpack", "nightly"] }
|
leptos = { version = "0.8.15", features = ["csr", "msgpack"] }
|
||||||
leptos_router = { version = "0.8.11" }
|
leptos_router = { version = "0.8.11" }
|
||||||
|
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
@@ -39,7 +39,6 @@ struct-patch = "0.5"
|
|||||||
leptos_ui = "0.3"
|
leptos_ui = "0.3"
|
||||||
tw_merge = "0.1"
|
tw_merge = "0.1"
|
||||||
strum = { version = "0.26", features = ["derive"] }
|
strum = { version = "0.26", features = ["derive"] }
|
||||||
icons = { version = "0.18.0", features = ["leptos"] }
|
|
||||||
|
|
||||||
[package.metadata.leptos]
|
[package.metadata.leptos]
|
||||||
tailwind-input-file = "input.css"
|
tailwind-input-file = "input.css"
|
||||||
@@ -20,12 +20,11 @@
|
|||||||
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
|
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
|
||||||
|
|
||||||
<!-- Trunk Assets -->
|
<!-- Trunk Assets -->
|
||||||
<script data-trunk rel="rust" src="Cargo.toml" data-wasm-opt="0" data-preload="false"></script>
|
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" />
|
||||||
<link data-trunk rel="css" href="public/tailwind.css" />
|
<link data-trunk rel="css" href="public/tailwind.css" />
|
||||||
<link data-trunk rel="copy-file" href="manifest.json" />
|
<link data-trunk rel="copy-file" href="manifest.json" />
|
||||||
<link data-trunk rel="copy-file" href="icon-192.png" />
|
<link data-trunk rel="copy-file" href="icon-192.png" />
|
||||||
<link data-trunk rel="copy-file" href="icon-512.png" />
|
<link data-trunk rel="copy-file" href="icon-512.png" />
|
||||||
<link data-trunk rel="copy-file" href="public/lock_scroll.js" />
|
|
||||||
<link data-trunk rel="copy-file" href="sw.js" />
|
<link data-trunk rel="copy-file" href="sw.js" />
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@@ -1,91 +1,177 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@config "./tailwind.config.js";
|
||||||
|
@source "../src/**/*.rs";
|
||||||
|
@source "/Users/bilal/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/leptos-shadcn-*/src/**/*.rs";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Shadcn Colors */
|
||||||
|
--color-border: hsl(var(--border));
|
||||||
|
--color-input: hsl(var(--input));
|
||||||
|
--color-ring: hsl(var(--ring));
|
||||||
|
--color-background: hsl(var(--background));
|
||||||
|
--color-foreground: hsl(var(--foreground));
|
||||||
|
|
||||||
:root {
|
--color-primary: hsl(var(--primary));
|
||||||
--radius: 0.625rem;
|
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.145 0 0);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.145 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
|
||||||
--primary: oklch(0.205 0 0);
|
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
|
||||||
--secondary: oklch(0.97 0 0);
|
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
|
||||||
--muted: oklch(0.97 0 0);
|
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
|
||||||
--accent: oklch(0.97 0 0);
|
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--border: oklch(0.922 0 0);
|
|
||||||
--input: oklch(0.922 0 0);
|
|
||||||
--ring: oklch(0.708 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
--color-secondary: hsl(var(--secondary));
|
||||||
--background: oklch(0.145 0 0);
|
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.205 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.205 0 0);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.922 0 0);
|
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
|
||||||
--ring: oklch(0.556 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
--color-destructive: hsl(var(--destructive));
|
||||||
|
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||||
|
|
||||||
@theme inline {
|
--color-muted: hsl(var(--muted));
|
||||||
--color-background: var(--background);
|
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-card: var(--card);
|
--color-accent: hsl(var(--accent));
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover: hsl(var(--popover));
|
||||||
--color-primary: var(--primary);
|
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
--color-card: hsl(var(--card));
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--radius-lg: var(--radius);
|
||||||
--color-accent: var(--accent);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||||
--color-border: var(--border);
|
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||||
--color-input: var(--input);
|
|
||||||
--color-ring: var(--ring);
|
@keyframes accordion-down {
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
from {
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
height: 0;
|
||||||
--radius-lg: var(--radius);
|
}
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
|
to {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes accordion-up {
|
||||||
|
from {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
:root {
|
||||||
@apply border-border outline-ring/50;
|
--background: 0 0% 100%;
|
||||||
}
|
--foreground: 222.2 84% 4.9%;
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:not(:disabled),
|
--card: 0 0% 100%;
|
||||||
[role="button"]:not(:disabled) {
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog {
|
--popover: 0 0% 100%;
|
||||||
margin: auto;
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
}
|
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure Shadcn Utilities are always available */
|
||||||
|
.bg-popover {
|
||||||
|
background-color: hsl(var(--popover));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-popover-foreground {
|
||||||
|
color: hsl(var(--popover-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-border {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-md {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.z-50 {
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.z-100 {
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix for iOS click/blur events */
|
||||||
|
@media (hover: none) {
|
||||||
|
body {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove default focus outline/tap highlight */
|
||||||
|
* {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
@@ -13,8 +13,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7"
|
||||||
"tw-animate-css": "^1.4.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
@@ -3738,15 +3737,6 @@
|
|||||||
"node": ">=8.0"
|
"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": {
|
"node_modules/universalify": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||||
|
|||||||
@@ -5,8 +5,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7"
|
||||||
"tw-animate-css": "^1.4.0"
|
|
||||||
},
|
},
|
||||||
"description": "",
|
"description": "",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -26,4 +25,4 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
/**
|
|
||||||
* Scroll Lock Utility
|
|
||||||
* Handles locking and unlocking scroll for both window and all scrollable containers
|
|
||||||
* Similar to react-remove-scroll but in vanilla JavaScript
|
|
||||||
*/
|
|
||||||
|
|
||||||
(() => {
|
|
||||||
// Prevent multiple initializations
|
|
||||||
if (window.ScrollLock) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ScrollLock {
|
|
||||||
constructor() {
|
|
||||||
this.locked = false;
|
|
||||||
this.scrollableElements = [];
|
|
||||||
this.scrollPositions = new Map();
|
|
||||||
this.originalStyles = new Map();
|
|
||||||
this.fixedElements = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find all scrollable elements in the DOM (optimized)
|
|
||||||
* Uses more targeted selectors instead of querying all elements
|
|
||||||
*/
|
|
||||||
findScrollableElements() {
|
|
||||||
const scrollables = [];
|
|
||||||
|
|
||||||
// More targeted query - only look for elements with overflow properties
|
|
||||||
const candidates = document.querySelectorAll(
|
|
||||||
'[style*="overflow"], [class*="overflow"], [class*="scroll"], main, aside, section, div',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Batch all style reads first to minimize reflows
|
|
||||||
const elementsToCheck = [];
|
|
||||||
for (const el of candidates) {
|
|
||||||
// Skip the element itself or if it's inside these containers
|
|
||||||
const dataName = el.getAttribute("data-name");
|
|
||||||
const isExcludedElement =
|
|
||||||
dataName === "ScrollArea" ||
|
|
||||||
dataName === "CommandList" ||
|
|
||||||
dataName === "SelectContent" ||
|
|
||||||
dataName === "MultiSelectContent" ||
|
|
||||||
dataName === "DropdownMenuContent" ||
|
|
||||||
dataName === "ContextMenuContent";
|
|
||||||
|
|
||||||
if (
|
|
||||||
el !== document.body &&
|
|
||||||
el !== document.documentElement &&
|
|
||||||
!isExcludedElement &&
|
|
||||||
!el.closest('[data-name="ScrollArea"]') &&
|
|
||||||
!el.closest('[data-name="CommandList"]') &&
|
|
||||||
!el.closest('[data-name="SelectContent"]') &&
|
|
||||||
!el.closest('[data-name="MultiSelectContent"]') &&
|
|
||||||
!el.closest('[data-name="DropdownMenuContent"]') &&
|
|
||||||
!el.closest('[data-name="ContextMenuContent"]')
|
|
||||||
) {
|
|
||||||
elementsToCheck.push(el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now batch read all computed styles and dimensions
|
|
||||||
elementsToCheck.forEach((el) => {
|
|
||||||
const style = window.getComputedStyle(el);
|
|
||||||
const hasOverflow =
|
|
||||||
style.overflow === "auto" ||
|
|
||||||
style.overflow === "scroll" ||
|
|
||||||
style.overflowY === "auto" ||
|
|
||||||
style.overflowY === "scroll";
|
|
||||||
|
|
||||||
// Only check scrollHeight if overflow is set
|
|
||||||
if (hasOverflow && el.scrollHeight > el.clientHeight) {
|
|
||||||
scrollables.push(el);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return scrollables;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lock scrolling on all scrollable elements (optimized)
|
|
||||||
* Batches all DOM reads before DOM writes to prevent forced reflows
|
|
||||||
*/
|
|
||||||
lock() {
|
|
||||||
if (this.locked) return;
|
|
||||||
|
|
||||||
this.locked = true;
|
|
||||||
|
|
||||||
// Find all scrollable elements
|
|
||||||
this.scrollableElements = this.findScrollableElements();
|
|
||||||
|
|
||||||
// ===== BATCH 1: READ PHASE - Read all layout properties first =====
|
|
||||||
const windowScrollY = window.scrollY;
|
|
||||||
const scrollbarWidth = window.innerWidth - document.body.clientWidth;
|
|
||||||
|
|
||||||
// Store window scroll position
|
|
||||||
this.scrollPositions.set("window", windowScrollY);
|
|
||||||
|
|
||||||
// Store original body styles
|
|
||||||
this.originalStyles.set("body", {
|
|
||||||
position: document.body.style.position,
|
|
||||||
top: document.body.style.top,
|
|
||||||
width: document.body.style.width,
|
|
||||||
overflow: document.body.style.overflow,
|
|
||||||
paddingRight: document.body.style.paddingRight,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read all fixed-position elements and their padding (only if we have scrollbar)
|
|
||||||
if (scrollbarWidth > 0) {
|
|
||||||
// Use more targeted query for fixed elements
|
|
||||||
const fixedCandidates = document.querySelectorAll(
|
|
||||||
'[style*="fixed"], [class*="fixed"], header, nav, aside, [role="dialog"], [role="alertdialog"]',
|
|
||||||
);
|
|
||||||
|
|
||||||
this.fixedElements = Array.from(fixedCandidates).filter((el) => {
|
|
||||||
const style = window.getComputedStyle(el);
|
|
||||||
return (
|
|
||||||
style.position === "fixed" &&
|
|
||||||
!el.closest('[data-name="DropdownMenuContent"]') &&
|
|
||||||
!el.closest('[data-name="MultiSelectContent"]') &&
|
|
||||||
!el.closest('[data-name="ContextMenuContent"]')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Batch read all padding values
|
|
||||||
this.fixedElements.forEach((el) => {
|
|
||||||
const computedStyle = window.getComputedStyle(el);
|
|
||||||
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
|
|
||||||
|
|
||||||
this.originalStyles.set(el, {
|
|
||||||
paddingRight: el.style.paddingRight,
|
|
||||||
computedPadding: currentPadding,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read scrollable elements info
|
|
||||||
const scrollableInfo = this.scrollableElements.map((el) => {
|
|
||||||
const scrollTop = el.scrollTop;
|
|
||||||
const elementScrollbarWidth = el.offsetWidth - el.clientWidth;
|
|
||||||
const computedStyle = window.getComputedStyle(el);
|
|
||||||
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
|
|
||||||
|
|
||||||
this.scrollPositions.set(el, scrollTop);
|
|
||||||
this.originalStyles.set(el, {
|
|
||||||
overflow: el.style.overflow,
|
|
||||||
overflowY: el.style.overflowY,
|
|
||||||
paddingRight: el.style.paddingRight,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { el, elementScrollbarWidth, currentPadding };
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== BATCH 2: WRITE PHASE - Apply all styles at once =====
|
|
||||||
|
|
||||||
// Apply body lock
|
|
||||||
document.body.style.position = "fixed";
|
|
||||||
document.body.style.top = `-${windowScrollY}px`;
|
|
||||||
document.body.style.width = "100%";
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
|
|
||||||
if (scrollbarWidth > 0) {
|
|
||||||
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
|
||||||
|
|
||||||
// Apply padding compensation to fixed elements
|
|
||||||
this.fixedElements.forEach((el) => {
|
|
||||||
const stored = this.originalStyles.get(el);
|
|
||||||
if (stored) {
|
|
||||||
el.style.paddingRight = `${stored.computedPadding + scrollbarWidth}px`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock all scrollable containers
|
|
||||||
scrollableInfo.forEach(({ el, elementScrollbarWidth, currentPadding }) => {
|
|
||||||
el.style.overflow = "hidden";
|
|
||||||
|
|
||||||
if (elementScrollbarWidth > 0) {
|
|
||||||
el.style.paddingRight = `${currentPadding + elementScrollbarWidth}px`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unlock scrolling on all elements (optimized)
|
|
||||||
* @param {number} delay - Delay in milliseconds before unlocking (for animations)
|
|
||||||
*/
|
|
||||||
unlock(delay = 0) {
|
|
||||||
if (!this.locked) return;
|
|
||||||
|
|
||||||
const performUnlock = () => {
|
|
||||||
// Restore body scroll
|
|
||||||
const bodyStyles = this.originalStyles.get("body");
|
|
||||||
if (bodyStyles) {
|
|
||||||
document.body.style.position = bodyStyles.position;
|
|
||||||
document.body.style.top = bodyStyles.top;
|
|
||||||
document.body.style.width = bodyStyles.width;
|
|
||||||
document.body.style.overflow = bodyStyles.overflow;
|
|
||||||
document.body.style.paddingRight = bodyStyles.paddingRight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore window scroll position
|
|
||||||
const windowScrollY = this.scrollPositions.get("window") || 0;
|
|
||||||
window.scrollTo(0, windowScrollY);
|
|
||||||
|
|
||||||
// Restore all scrollable containers
|
|
||||||
this.scrollableElements.forEach((el) => {
|
|
||||||
const originalStyles = this.originalStyles.get(el);
|
|
||||||
if (originalStyles) {
|
|
||||||
el.style.overflow = originalStyles.overflow;
|
|
||||||
el.style.overflowY = originalStyles.overflowY;
|
|
||||||
el.style.paddingRight = originalStyles.paddingRight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore scroll position
|
|
||||||
const scrollPosition = this.scrollPositions.get(el) || 0;
|
|
||||||
el.scrollTop = scrollPosition;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore fixed-position elements padding
|
|
||||||
this.fixedElements.forEach((el) => {
|
|
||||||
const styles = this.originalStyles.get(el);
|
|
||||||
if (styles && styles.paddingRight !== undefined) {
|
|
||||||
el.style.paddingRight = styles.paddingRight;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear storage
|
|
||||||
this.scrollableElements = [];
|
|
||||||
this.fixedElements = [];
|
|
||||||
this.scrollPositions.clear();
|
|
||||||
this.originalStyles.clear();
|
|
||||||
this.locked = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (delay > 0) {
|
|
||||||
setTimeout(performUnlock, delay);
|
|
||||||
} else {
|
|
||||||
performUnlock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if scrolling is currently locked
|
|
||||||
*/
|
|
||||||
isLocked() {
|
|
||||||
return this.locked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export as singleton
|
|
||||||
window.ScrollLock = new ScrollLock();
|
|
||||||
})();
|
|
||||||
@@ -1,39 +1,19 @@
|
|||||||
use crate::components::layout::protected::ProtectedLayout;
|
use crate::components::layout::protected::Protected;
|
||||||
use crate::components::ui::skeleton::Skeleton;
|
|
||||||
use crate::components::torrent::table::TorrentTable;
|
use crate::components::torrent::table::TorrentTable;
|
||||||
use crate::components::auth::login::Login;
|
use crate::components::auth::login::Login;
|
||||||
use crate::components::auth::setup::Setup;
|
use crate::components::auth::setup::Setup;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use leptos_router::components::{Router, Routes, Route};
|
use leptos_router::components::{Router, Routes, Route};
|
||||||
use leptos_router::hooks::{use_navigate, use_location};
|
use leptos_router::hooks::use_navigate;
|
||||||
use crate::components::ui::toast::Toaster;
|
use crate::components::ui::toast::Toaster;
|
||||||
use crate::components::hooks::use_theme_mode::ThemeMode;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
crate::components::ui::toast::provide_toaster();
|
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! {
|
view! {
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Router>
|
<InnerApp />
|
||||||
<InnerApp />
|
|
||||||
</Router>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +21,6 @@ pub fn App() -> impl IntoView {
|
|||||||
fn InnerApp() -> impl IntoView {
|
fn InnerApp() -> impl IntoView {
|
||||||
crate::store::provide_torrent_store();
|
crate::store::provide_torrent_store();
|
||||||
let store = use_context::<crate::store::TorrentStore>();
|
let store = use_context::<crate::store::TorrentStore>();
|
||||||
let _loc = use_location();
|
|
||||||
|
|
||||||
let is_loading = signal(true);
|
let is_loading = signal(true);
|
||||||
let is_authenticated = signal(false);
|
let is_authenticated = signal(false);
|
||||||
@@ -50,11 +29,13 @@ fn InnerApp() -> impl IntoView {
|
|||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
log::info!("App initialization started...");
|
log::info!("App initialization started...");
|
||||||
|
gloo_console::log!("APP INIT: Checking setup status...");
|
||||||
|
|
||||||
// Check if setup is needed via Server Function
|
// Check if setup is needed via Server Function
|
||||||
match shared::server_fns::auth::get_setup_status().await {
|
match shared::server_fns::auth::get_setup_status().await {
|
||||||
Ok(status) => {
|
Ok(status) => {
|
||||||
if !status.completed {
|
if !status.completed {
|
||||||
|
log::info!("Setup not completed");
|
||||||
needs_setup.1.set(true);
|
needs_setup.1.set(true);
|
||||||
is_loading.1.set(false);
|
is_loading.1.set(false);
|
||||||
return;
|
return;
|
||||||
@@ -66,6 +47,7 @@ fn InnerApp() -> impl IntoView {
|
|||||||
// Check authentication via GetUser Server Function
|
// Check authentication via GetUser Server Function
|
||||||
match shared::server_fns::auth::get_user().await {
|
match shared::server_fns::auth::get_user().await {
|
||||||
Ok(Some(user_info)) => {
|
Ok(Some(user_info)) => {
|
||||||
|
log::info!("Authenticated as {}", user_info.username);
|
||||||
if let Some(s) = store {
|
if let Some(s) = store {
|
||||||
s.user.set(Some(user_info.username));
|
s.user.set(Some(user_info.username));
|
||||||
}
|
}
|
||||||
@@ -80,6 +62,7 @@ fn InnerApp() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
is_loading.1.set(false);
|
is_loading.1.set(false);
|
||||||
|
crate::store::toast_success("VibeTorrent'e Hoşgeldiniz");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,108 +78,121 @@ fn InnerApp() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let is_loading_val = move || is_loading.0.get();
|
|
||||||
let authenticated_val = move || is_authenticated.0.get();
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="relative w-full h-screen" style="height: 100dvh;">
|
<div class="relative w-full h-screen" style="height: 100dvh;">
|
||||||
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }.into_any()>
|
<Router>
|
||||||
<Route path=leptos_router::path!("/login") view=move || {
|
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
|
||||||
let authenticated = authenticated_val();
|
<Route path=leptos_router::path!("/login") view=move || {
|
||||||
let setup_needed = needs_setup.0.get();
|
let authenticated = is_authenticated.0.get();
|
||||||
|
let setup_needed = needs_setup.0.get();
|
||||||
Effect::new(move |_| {
|
|
||||||
if setup_needed {
|
Effect::new(move |_| {
|
||||||
let navigate = use_navigate();
|
if setup_needed {
|
||||||
navigate("/setup", Default::default());
|
let navigate = use_navigate();
|
||||||
} else if authenticated {
|
|
||||||
let navigate = use_navigate();
|
|
||||||
navigate("/", Default::default());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
view! { <Login /> }.into_any()
|
|
||||||
} />
|
|
||||||
<Route path=leptos_router::path!("/setup") view=move || {
|
|
||||||
Effect::new(move |_| {
|
|
||||||
if authenticated_val() {
|
|
||||||
let navigate = use_navigate();
|
|
||||||
navigate("/", Default::default());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
view! { <Setup /> }.into_any()
|
|
||||||
} />
|
|
||||||
|
|
||||||
<Route path=leptos_router::path!("/") view=move || {
|
|
||||||
let navigate = use_navigate();
|
|
||||||
Effect::new(move |_| {
|
|
||||||
if !is_loading_val() {
|
|
||||||
if needs_setup.0.get() {
|
|
||||||
navigate("/setup", Default::default());
|
navigate("/setup", Default::default());
|
||||||
} else if !authenticated_val() {
|
} else if authenticated {
|
||||||
navigate("/login", Default::default());
|
log::info!("Already authenticated, redirecting to home");
|
||||||
|
let navigate = use_navigate();
|
||||||
|
navigate("/", Default::default());
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
view! { <Login /> }
|
||||||
view! {
|
} />
|
||||||
<Show when=move || !is_loading_val() fallback=|| {
|
<Route path=leptos_router::path!("/setup") view=move || {
|
||||||
view! {
|
Effect::new(move |_| {
|
||||||
<div class="flex h-screen bg-background text-foreground overflow-hidden">
|
if is_authenticated.0.get() {
|
||||||
|
let navigate = use_navigate();
|
||||||
|
navigate("/", Default::default());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! { <Setup /> }
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path=leptos_router::path!("/") view=move || {
|
||||||
|
let navigate = use_navigate();
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if !is_loading.0.get() {
|
||||||
|
if needs_setup.0.get() {
|
||||||
|
log::info!("Setup not completed, redirecting to setup");
|
||||||
|
navigate("/setup", Default::default());
|
||||||
|
} else if !is_authenticated.0.get() {
|
||||||
|
log::info!("Not authenticated, redirecting to login");
|
||||||
|
navigate("/login", Default::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Show when=move || !is_loading.0.get() fallback=|| view! {
|
||||||
|
<div class="flex h-screen bg-background">
|
||||||
|
// Sidebar skeleton
|
||||||
<div class="w-56 border-r border-border p-4 space-y-4">
|
<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">
|
<div class="space-y-2">
|
||||||
<Skeleton class="h-6 w-full" />
|
<div class="h-6 w-full animate-pulse rounded-md bg-muted" />
|
||||||
<Skeleton class="h-6 w-full" />
|
<div class="h-6 w-full animate-pulse rounded-md bg-muted" />
|
||||||
<Skeleton class="h-6 w-4/5" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 flex flex-col min-w-0">
|
// Main content skeleton
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
<div class="border-b border-border p-4 flex items-center gap-4">
|
<div class="border-b border-border p-4 flex items-center gap-4">
|
||||||
<Skeleton class="h-8 w-48" />
|
<div class="h-8 w-48 animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-8 w-64 animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="ml-auto"><div class="h-8 w-24 animate-pulse rounded-md bg-muted" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 p-4 space-y-3">
|
<div class="flex-1 p-4 space-y-3">
|
||||||
<Skeleton class="h-10 w-full" />
|
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
|
||||||
<Skeleton class="h-10 w-full" />
|
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
<div class="h-10 w-3/4 animate-pulse rounded-md bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-border p-3">
|
||||||
|
<div class="h-5 w-96 animate-pulse rounded-md bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}.into_any()
|
}.into_any()>
|
||||||
}>
|
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
||||||
<Show when=move || authenticated_val() fallback=|| ()>
|
<Protected>
|
||||||
<ProtectedLayout>
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
<div class="flex flex-col h-full overflow-hidden">
|
<div class="flex-1 overflow-hidden">
|
||||||
<div class="flex-1 overflow-hidden">
|
<TorrentTable />
|
||||||
<TorrentTable />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Protected>
|
||||||
</ProtectedLayout>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
}.into_any()
|
||||||
}.into_any()
|
}/>
|
||||||
}/>
|
|
||||||
|
|
||||||
<Route path=leptos_router::path!("/settings") view=move || {
|
<Route path=leptos_router::path!("/settings") view=move || {
|
||||||
let authenticated = authenticated_val();
|
Effect::new(move |_| {
|
||||||
Effect::new(move |_| {
|
if !is_authenticated.0.get() {
|
||||||
if !authenticated {
|
let navigate = use_navigate();
|
||||||
let navigate = use_navigate();
|
navigate("/login", Default::default());
|
||||||
navigate("/login", Default::default());
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
view! {
|
||||||
view! {
|
<Show when=move || !is_loading.0.get() fallback=|| ()>
|
||||||
<Show when=move || !is_loading_val() fallback=|| ()>
|
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
||||||
<Show when=move || authenticated fallback=|| ()>
|
<Protected>
|
||||||
<ProtectedLayout>
|
<div class="p-4">"Settings Page (Coming Soon)"</div>
|
||||||
<div class="p-4">"Settings Page (Coming Soon)"</div>
|
</Protected>
|
||||||
</ProtectedLayout>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
}
|
||||||
}.into_any()
|
}/>
|
||||||
}/>
|
</Routes>
|
||||||
</Routes>
|
</Router>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ use leptos::task::spawn_local;
|
|||||||
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
||||||
use crate::components::ui::input::{Input, InputType};
|
use crate::components::ui::input::{Input, InputType};
|
||||||
|
|
||||||
use crate::components::ui::button::Button;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Login() -> impl IntoView {
|
pub fn Login() -> impl IntoView {
|
||||||
let username = RwSignal::new(String::new());
|
let username = RwSignal::new(String::new());
|
||||||
@@ -76,16 +74,15 @@ pub fn Login() -> impl IntoView {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="pt-2">
|
<div class="pt-2">
|
||||||
<Button
|
<button
|
||||||
class="w-full"
|
class="inline-flex items-center justify-center w-full h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
|
||||||
attr:r#type="submit"
|
disabled=move || loading.0.get()
|
||||||
attr:disabled=move || loading.0.get()
|
|
||||||
>
|
>
|
||||||
<Show when=move || loading.0.get() fallback=|| view! { "Giriş Yap" }.into_any()>
|
<Show when=move || loading.0.get() fallback=|| "Giriş Yap">
|
||||||
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
||||||
"Giriş Yapılıyor..."
|
"Giriş Yapılıyor..."
|
||||||
</Show>
|
</Show>
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ use leptos::task::spawn_local;
|
|||||||
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
||||||
use crate::components::ui::input::{Input, InputType};
|
use crate::components::ui::input::{Input, InputType};
|
||||||
|
|
||||||
use crate::components::ui::button::Button;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Setup() -> impl IntoView {
|
pub fn Setup() -> impl IntoView {
|
||||||
let username = RwSignal::new(String::new());
|
let username = RwSignal::new(String::new());
|
||||||
@@ -43,7 +41,8 @@ pub fn Setup() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Setup failed: {:?}", e);
|
log::error!("Setup failed: {:?}", e);
|
||||||
error.1.set(Some("Kurulum sırasında bir hata oluştu".to_string()));
|
// Hatanın sadece mesaj kısmını almaya çalışalım, yoksa full struct basılabilir
|
||||||
|
error.1.set(Some(format!("Hata: {}", e)));
|
||||||
loading.1.set(false);
|
loading.1.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,16 +99,15 @@ pub fn Setup() -> impl IntoView {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="pt-2">
|
<div class="pt-2">
|
||||||
<Button
|
<button
|
||||||
class="w-full"
|
class="inline-flex items-center justify-center w-full h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
|
||||||
attr:r#type="submit"
|
disabled=move || loading.0.get()
|
||||||
attr:disabled=move || loading.0.get()
|
|
||||||
>
|
>
|
||||||
<Show when=move || loading.0.get() fallback=|| view! { "Kurulumu Tamamla" }.into_any()>
|
<Show when=move || loading.0.get() fallback=|| "Kurulumu Tamamla">
|
||||||
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
||||||
"Kuruluyor..."
|
"Kuruluyor..."
|
||||||
</Show>
|
</Show>
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use crate::components::ui::context_menu::{
|
use web_sys::MouseEvent;
|
||||||
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
|
use wasm_bindgen::prelude::*;
|
||||||
};
|
use wasm_bindgen::JsCast;
|
||||||
use crate::components::ui::button_action::ButtonAction;
|
|
||||||
use crate::components::ui::button::ButtonVariant;
|
// ── Kendi reaktif Context Menu implementasyonumuz ──
|
||||||
|
// leptos-shadcn-context-menu v0.8.1'de ContextMenuContent'te
|
||||||
|
// `if open.get()` statik kontrolü reaktif değil. Aşağıda
|
||||||
|
// `Show` bileşeni ile düzgün reaktif versiyon yer alıyor.
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn TorrentContextMenu(
|
pub fn TorrentContextMenu(
|
||||||
@@ -11,61 +14,145 @@ pub fn TorrentContextMenu(
|
|||||||
torrent_hash: String,
|
torrent_hash: String,
|
||||||
on_action: Callback<(String, String)>,
|
on_action: Callback<(String, String)>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let hash_c1 = torrent_hash.clone();
|
let hash = StoredValue::new(torrent_hash);
|
||||||
let hash_c2 = torrent_hash.clone();
|
let on_action = StoredValue::new(on_action);
|
||||||
let hash_c3 = torrent_hash.clone();
|
|
||||||
let hash_c4 = torrent_hash.clone();
|
let open = RwSignal::new(false);
|
||||||
|
let position = RwSignal::new((0i32, 0i32));
|
||||||
let on_action_stored = StoredValue::new(on_action);
|
|
||||||
|
// Sağ tıklama handler
|
||||||
|
let on_contextmenu = move |e: MouseEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
e.stop_propagation();
|
||||||
|
position.set((e.client_x(), e.client_y()));
|
||||||
|
open.set(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Menü dışına tıklandığında kapanma
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if open.get() {
|
||||||
|
let cb = Closure::wrap(Box::new(move |_: MouseEvent| {
|
||||||
|
open.set(false);
|
||||||
|
}) as Box<dyn Fn(MouseEvent)>);
|
||||||
|
|
||||||
|
let window = web_sys::window().unwrap();
|
||||||
|
let document = window.document().unwrap();
|
||||||
|
let _ = document.add_event_listener_with_callback(
|
||||||
|
"click",
|
||||||
|
cb.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup: tek sefer dinleyici — click yakalandığında otomatik kapanıp listener kalıyor
|
||||||
|
// ama open=false olduğunda effect tekrar çalışmaz, böylece sorun yok.
|
||||||
|
cb.forget();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let menu_action = move |action: &'static str| {
|
||||||
|
open.set(false);
|
||||||
|
on_action.get_value().run((action.to_string(), hash.get_value()));
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<ContextMenu>
|
<div
|
||||||
<ContextMenuTrigger>
|
class="w-full"
|
||||||
{children()}
|
on:contextmenu=on_contextmenu
|
||||||
</ContextMenuTrigger>
|
>
|
||||||
<ContextMenuContent class="w-56 p-1.5">
|
{children()}
|
||||||
<ContextMenuItem on:click={let h = hash_c1; move |_| {
|
</div>
|
||||||
on_action_stored.get_value().run(("start".to_string(), h.clone()));
|
|
||||||
crate::components::ui::context_menu::close_context_menu();
|
|
||||||
}}>
|
|
||||||
"Başlat"
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem on:click={let h = hash_c2; move |_| {
|
|
||||||
on_action_stored.get_value().run(("stop".to_string(), h.clone()));
|
|
||||||
crate::components::ui::context_menu::close_context_menu();
|
|
||||||
}}>
|
|
||||||
"Durdur"
|
|
||||||
</ContextMenuItem>
|
|
||||||
|
|
||||||
<div class="my-1.5 h-px bg-border/50" />
|
|
||||||
|
|
||||||
// --- Modern Hold-to-Action Buttons ---
|
|
||||||
<div class="space-y-1">
|
|
||||||
<ButtonAction
|
|
||||||
variant=ButtonVariant::Ghost
|
|
||||||
class="w-full justify-start h-8 px-2 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive transition-none"
|
|
||||||
hold_duration=800
|
|
||||||
on_action={let h = hash_c3; move || {
|
|
||||||
on_action_stored.get_value().run(("delete".to_string(), h.clone()));
|
|
||||||
crate::components::ui::context_menu::close_context_menu();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
"Sil (Basılı Tut)"
|
|
||||||
</ButtonAction>
|
|
||||||
|
|
||||||
<ButtonAction
|
<Show when=move || open.get()>
|
||||||
variant=ButtonVariant::Destructive
|
{
|
||||||
class="w-full justify-start h-8 px-2 text-xs font-bold"
|
let (x, y) = position.get();
|
||||||
hold_duration=1200
|
// Menü yaklaşık boyutları
|
||||||
on_action={let h = hash_c4; move || {
|
let menu_width = 200;
|
||||||
on_action_stored.get_value().run(("delete_with_data".to_string(), h.clone()));
|
let menu_height = 220;
|
||||||
crate::components::ui::context_menu::close_context_menu();
|
let window = web_sys::window().unwrap();
|
||||||
}}
|
let vw = window.inner_width().unwrap().as_f64().unwrap() as i32;
|
||||||
|
let vh = window.inner_height().unwrap().as_f64().unwrap() as i32;
|
||||||
|
// Sağa taşarsa sola aç, alta taşarsa yukarı aç
|
||||||
|
let final_x = if x + menu_width > vw { x - menu_width } else { x };
|
||||||
|
let final_y = if y + menu_height > vh { y - menu_height } else { y };
|
||||||
|
let final_x = final_x.max(0);
|
||||||
|
let final_y = final_y.max(0);
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[99]"
|
||||||
|
on:click=move |e: MouseEvent| {
|
||||||
|
e.stop_propagation();
|
||||||
|
open.set(false);
|
||||||
|
}
|
||||||
|
on:contextmenu=move |e: MouseEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
e.stop_propagation();
|
||||||
|
open.set(false);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="fixed z-[100] min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
|
||||||
|
style=format!("left: {}px; top: {}px;", final_x, final_y)
|
||||||
|
on:click=move |e: MouseEvent| e.stop_propagation()
|
||||||
>
|
>
|
||||||
"Verilerle Sil (Basılı Tut)"
|
// Start
|
||||||
</ButtonAction>
|
<div
|
||||||
</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"
|
||||||
</ContextMenuContent>
|
on:click=move |_| menu_action("start")
|
||||||
</ContextMenu>
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||||
|
</svg>
|
||||||
|
"Start"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Stop
|
||||||
|
<div
|
||||||
|
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
on:click=move |_| menu_action("stop")
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||||
|
</svg>
|
||||||
|
"Stop"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Recheck
|
||||||
|
<div
|
||||||
|
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
on:click=move |_| menu_action("recheck")
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
|
</svg>
|
||||||
|
"Recheck"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
<div class="-mx-1 my-1 h-px bg-border" />
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
<div
|
||||||
|
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
on:click=move |_| menu_action("delete")
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.164h-2.34c-1.18 0-2.09.984-2.09 2.164v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||||
|
</svg>
|
||||||
|
"Remove"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Remove with Data
|
||||||
|
<div
|
||||||
|
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
on:click=move |_| menu_action("delete_with_data")
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||||
|
</svg>
|
||||||
|
"Remove with Data"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</Show>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod use_random;
|
|
||||||
pub mod use_theme_mode;
|
|
||||||
pub mod use_can_scroll_vertical;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
|
|
||||||
/// Hook to determine if an element can scroll vertically.
|
|
||||||
///
|
|
||||||
/// Returns (on_scroll_callback, can_scroll_up_signal, can_scroll_down_signal)
|
|
||||||
pub fn use_can_scroll_vertical() -> (Callback<web_sys::Event>, ReadSignal<bool>, ReadSignal<bool>) {
|
|
||||||
let can_scroll_up = RwSignal::new(false);
|
|
||||||
let can_scroll_down = RwSignal::new(false);
|
|
||||||
|
|
||||||
let on_scroll = Callback::new(move |ev: web_sys::Event| {
|
|
||||||
if let Some(target) = ev.target() {
|
|
||||||
if let Some(el) = target.dyn_ref::<web_sys::HtmlElement>() {
|
|
||||||
let scroll_top = el.scroll_top();
|
|
||||||
let scroll_height = el.scroll_height();
|
|
||||||
let client_height = el.client_height();
|
|
||||||
|
|
||||||
can_scroll_up.set(scroll_top > 0);
|
|
||||||
can_scroll_down.set(scroll_top + client_height < scroll_height - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
(on_scroll, can_scroll_up.read_only(), can_scroll_down.read_only())
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
|
|
||||||
pub fn use_random_id_for(prefix: &str) -> String {
|
|
||||||
format!("{}_{}", prefix, js_sys::Math::random().to_string().replace(".", ""))
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use web_sys::Storage;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct ThemeMode {
|
|
||||||
state: RwSignal<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOCALSTORAGE_KEY: &str = "darkmode";
|
|
||||||
|
|
||||||
/// Hook to access the dark mode context
|
|
||||||
///
|
|
||||||
/// Returns the ThemeMode instance from context for easy access
|
|
||||||
pub fn use_theme_mode() -> ThemeMode {
|
|
||||||
expect_context::<ThemeMode>()
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
impl ThemeMode {
|
|
||||||
#[must_use]
|
|
||||||
/// Initializes a new ThemeMode instance.
|
|
||||||
pub fn init() -> Self {
|
|
||||||
let theme_mode = Self { state: RwSignal::new(false) };
|
|
||||||
|
|
||||||
provide_context(theme_mode);
|
|
||||||
|
|
||||||
// Use Effect to handle browser-only initialization
|
|
||||||
Effect::new(move |_| {
|
|
||||||
let initial = Self::get_storage_state().unwrap_or(Self::prefers_dark_mode());
|
|
||||||
theme_mode.state.set(initial);
|
|
||||||
});
|
|
||||||
|
|
||||||
theme_mode
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn toggle(&self) {
|
|
||||||
self.state.update(|state| {
|
|
||||||
*state = !*state;
|
|
||||||
Self::set_storage_state(*state);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_dark(&self) {
|
|
||||||
self.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_light(&self) {
|
|
||||||
self.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// - `dark`: Set to `true` for dark mode, and `false` for light mode.
|
|
||||||
pub fn set(&self, dark: bool) {
|
|
||||||
self.state.set(dark);
|
|
||||||
Self::set_storage_state(dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn get(&self) -> bool {
|
|
||||||
self.state.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_dark(&self) -> bool {
|
|
||||||
self.state.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_light(&self) -> bool {
|
|
||||||
!self.state.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
/// Retrieves the local storage object, if available.
|
|
||||||
fn get_storage() -> Option<Storage> {
|
|
||||||
window().local_storage().ok().flatten()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieves the dark mode state from local storage, if available.
|
|
||||||
fn get_storage_state() -> Option<bool> {
|
|
||||||
Self::get_storage()
|
|
||||||
.and_then(|storage| storage.get(LOCALSTORAGE_KEY).ok())
|
|
||||||
.flatten()
|
|
||||||
.and_then(|entry| entry.parse::<bool>().ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks whether the user's system prefers dark mode based on media queries.
|
|
||||||
fn prefers_dark_mode() -> bool {
|
|
||||||
window()
|
|
||||||
.match_media("(prefers-color-scheme: dark)")
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.map(|media| media.matches())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stores the dark mode state in local storage.
|
|
||||||
fn set_storage_state(state: bool) {
|
|
||||||
if let Some(storage) = Self::get_storage() {
|
|
||||||
storage.set(LOCALSTORAGE_KEY, state.to_string().as_str()).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use crate::components::ui::separator::Separator;
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Footer() -> impl IntoView {
|
|
||||||
let year = chrono::Local::now().format("%Y").to_string();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<footer class="mt-auto pb-6 px-4">
|
|
||||||
<Separator class="mb-4 opacity-30" />
|
|
||||||
<div class="flex items-center justify-center gap-2 text-[10px] uppercase tracking-widest text-muted-foreground/60 font-medium">
|
|
||||||
<span>{format!("© {} VibeTorrent", year)}</span>
|
|
||||||
<span class="size-1 rounded-full bg-muted-foreground/30" />
|
|
||||||
<span>"v3.0.0-beta"</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
|
pub mod statusbar;
|
||||||
pub mod toolbar;
|
pub mod toolbar;
|
||||||
pub mod footer;
|
|
||||||
pub mod protected;
|
pub mod protected;
|
||||||
|
|||||||
@@ -1,24 +1,52 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use crate::components::layout::sidebar::Sidebar;
|
use crate::components::layout::sidebar::Sidebar;
|
||||||
use crate::components::layout::toolbar::Toolbar;
|
use crate::components::layout::toolbar::Toolbar;
|
||||||
use crate::components::ui::sidenav::{SidenavWrapper, Sidenav, SidenavInset, SidenavState};
|
use crate::components::layout::statusbar::StatusBar;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ProtectedLayout(children: Children) -> impl IntoView {
|
pub fn Protected(children: Children) -> impl IntoView {
|
||||||
let sidenav_state = RwSignal::new(SidenavState::Expanded);
|
// Mobil menü durumu için bir sinyal oluşturuyoruz (RwSignal for easier passing)
|
||||||
|
let is_mobile_menu_open = RwSignal::new(false);
|
||||||
|
|
||||||
|
// Sinyali context olarak sağlıyoruz ki Toolbar ve Sidebar buna erişebilsin
|
||||||
|
provide_context(is_mobile_menu_open);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<SidenavWrapper attr:style="--sidenav-width:16rem; --sidenav-width-icon:3rem;">
|
<div class="flex h-screen w-full overflow-hidden bg-background">
|
||||||
<Sidenav data_state=Signal::from(sidenav_state)>
|
|
||||||
<Sidebar />
|
|
||||||
</Sidenav>
|
|
||||||
|
|
||||||
<SidenavInset class="flex flex-col h-screen overflow-hidden">
|
// --- SIDEBAR (Desktop: Sabit, Mobil: Overlay) ---
|
||||||
<Toolbar sidenav_state=sidenav_state />
|
<aside class=move || {
|
||||||
<main class="flex-1 overflow-auto bg-muted/30">
|
let base = "fixed inset-y-0 left-0 z-50 w-64 transform transition-transform duration-300 ease-in-out border-r border-border bg-card lg:relative lg:translate-x-0";
|
||||||
|
if is_mobile_menu_open.get() {
|
||||||
|
format!("{} translate-x-0", base)
|
||||||
|
} else {
|
||||||
|
format!("{} -translate-x-full", base)
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<Sidebar />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
// Mobil arka plan karartma (Overlay)
|
||||||
|
<Show when=move || is_mobile_menu_open.get()>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm lg:hidden"
|
||||||
|
on:click=move |_| is_mobile_menu_open.set(false)
|
||||||
|
></div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
// --- MAIN CONTENT AREA ---
|
||||||
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
|
// --- TOOLBAR (TOP) ---
|
||||||
|
<Toolbar />
|
||||||
|
|
||||||
|
// --- MAIN CONTENT ---
|
||||||
|
<main class="flex-1 overflow-hidden relative bg-background">
|
||||||
{children()}
|
{children()}
|
||||||
</main>
|
</main>
|
||||||
</SidenavInset>
|
|
||||||
</SidenavWrapper>
|
// --- STATUS BAR (BOTTOM) ---
|
||||||
|
<StatusBar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use crate::components::ui::sidenav::*;
|
|
||||||
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
|
use leptos_use::storage::use_local_storage;
|
||||||
use crate::components::ui::theme_toggle::ThemeToggle;
|
use ::codee::string::FromToStringCodec;
|
||||||
use crate::components::ui::switch::Switch;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Sidebar() -> impl IntoView {
|
pub fn Sidebar() -> impl IntoView {
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
|
let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
|
||||||
|
|
||||||
let total_count = move || store.torrents.with(|map| map.len());
|
let total_count = move || store.torrents.with(|map| map.len());
|
||||||
let downloading_count = move || {
|
let downloading_count = move || {
|
||||||
@@ -54,6 +54,7 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
|
|
||||||
let set_filter = move |f: crate::store::FilterStatus| {
|
let set_filter = move |f: crate::store::FilterStatus| {
|
||||||
store.filter.set(f);
|
store.filter.set(f);
|
||||||
|
is_mobile_menu_open.set(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_active = move |f: crate::store::FilterStatus| store.filter.get() == f;
|
let is_active = move |f: crate::store::FilterStatus| store.filter.get() == f;
|
||||||
@@ -66,160 +67,161 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
username().chars().next().unwrap_or('?').to_uppercase().to_string()
|
username().chars().next().unwrap_or('?').to_uppercase().to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_push_toggle = move |checked: bool| {
|
// --- THEME LOGIC START ---
|
||||||
spawn_local(async move {
|
let (current_theme, set_current_theme, _) = use_local_storage::<String, FromToStringCodec>("vibetorrent_theme");
|
||||||
if checked {
|
|
||||||
crate::store::subscribe_to_push_notifications().await;
|
// Initialize with default if empty
|
||||||
|
let current_theme_val = current_theme.get();
|
||||||
|
if current_theme_val.is_empty() {
|
||||||
|
set_current_theme.set("dark".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatically sync theme to document attribute
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let theme = current_theme.get().to_lowercase();
|
||||||
|
if let Some(doc) = document().document_element() {
|
||||||
|
let _ = doc.set_attribute("data-theme", &theme);
|
||||||
|
if theme == "dark" || theme == "dracula" || theme == "dim" || theme == "abyss" || theme == "sunset" || theme == "cyberpunk" || theme == "nord" || theme == "business" || theme == "night" || theme == "black" || theme == "luxury" || theme == "coffee" || theme == "forest" || theme == "halloween" || theme == "synthwave" {
|
||||||
|
let _ = doc.class_list().add_1("dark");
|
||||||
} else {
|
} else {
|
||||||
crate::store::unsubscribe_from_push_notifications().await;
|
let _ = doc.class_list().remove_1("dark");
|
||||||
}
|
}
|
||||||
if let Ok(enabled) = crate::store::is_push_subscribed().await {
|
}
|
||||||
store.push_enabled.set(enabled);
|
});
|
||||||
}
|
|
||||||
});
|
let toggle_theme = move |_| {
|
||||||
|
let new_theme = if current_theme.get() == "dark" { "light" } else { "dark" };
|
||||||
|
set_current_theme.set(new_theme.to_string());
|
||||||
};
|
};
|
||||||
|
// --- THEME LOGIC END ---
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<SidenavHeader>
|
<div class="w-full h-full flex flex-col bg-card" style="padding-top: env(safe-area-inset-top);">
|
||||||
<div class="flex items-center gap-2 px-2 py-4">
|
<div class="p-4 flex-1 overflow-y-auto">
|
||||||
<div class="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
|
<div class="mb-4 px-2 text-lg font-semibold tracking-tight text-foreground">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
"VibeTorrent"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.25 0 003 2.48z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight overflow-hidden">
|
<div class="space-y-1">
|
||||||
<span class="truncate font-semibold text-foreground text-base">"VibeTorrent"</span>
|
<h4 class="mb-1 rounded-md px-2 py-1 text-sm font-semibold text-muted-foreground">"Filters"</h4>
|
||||||
<span class="truncate text-[10px] text-muted-foreground opacity-70">"v3.0.0"</span>
|
|
||||||
</div>
|
<SidebarButton
|
||||||
</div>
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::All))
|
||||||
</SidenavHeader>
|
on_click=move |_| set_filter(crate::store::FilterStatus::All)
|
||||||
|
icon="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||||
<SidenavContent>
|
label="All"
|
||||||
<SidenavGroup>
|
count=Signal::derive(total_count)
|
||||||
<SidenavGroupLabel>"Filtreler"</SidenavGroupLabel>
|
/>
|
||||||
<SidenavGroupContent>
|
<SidebarButton
|
||||||
<SidenavMenu>
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Downloading))
|
||||||
<SidebarItem
|
on_click=move |_| set_filter(crate::store::FilterStatus::Downloading)
|
||||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::All))
|
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"
|
||||||
on_click=move |_| set_filter(crate::store::FilterStatus::All)
|
label="Downloading"
|
||||||
icon="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
count=Signal::derive(downloading_count)
|
||||||
label="Tümü"
|
/>
|
||||||
count=Signal::derive(total_count)
|
<SidebarButton
|
||||||
/>
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Seeding))
|
||||||
<SidebarItem
|
on_click=move |_| set_filter(crate::store::FilterStatus::Seeding)
|
||||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Downloading))
|
icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||||
on_click=move |_| set_filter(crate::store::FilterStatus::Downloading)
|
label="Seeding"
|
||||||
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"
|
count=Signal::derive(seeding_count)
|
||||||
label="İndirilenler"
|
/>
|
||||||
count=Signal::derive(downloading_count)
|
<SidebarButton
|
||||||
/>
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Completed))
|
||||||
<SidebarItem
|
on_click=move |_| set_filter(crate::store::FilterStatus::Completed)
|
||||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Seeding))
|
icon="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
on_click=move |_| set_filter(crate::store::FilterStatus::Seeding)
|
label="Completed"
|
||||||
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"
|
count=Signal::derive(completed_count)
|
||||||
label="Gönderilenler"
|
/>
|
||||||
count=Signal::derive(seeding_count)
|
<SidebarButton
|
||||||
/>
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Paused))
|
||||||
<SidebarItem
|
on_click=move |_| set_filter(crate::store::FilterStatus::Paused)
|
||||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Completed))
|
icon="M15.75 5.25v13.5m-7.5-13.5v13.5"
|
||||||
on_click=move |_| set_filter(crate::store::FilterStatus::Completed)
|
label="Paused"
|
||||||
icon="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
count=Signal::derive(paused_count)
|
||||||
label="Tamamlananlar"
|
/>
|
||||||
count=Signal::derive(completed_count)
|
<SidebarButton
|
||||||
/>
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Inactive))
|
||||||
<SidebarItem
|
on_click=move |_| set_filter(crate::store::FilterStatus::Inactive)
|
||||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Paused))
|
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"
|
||||||
on_click=move |_| set_filter(crate::store::FilterStatus::Paused)
|
label="Inactive"
|
||||||
icon="M15.75 5.25v13.5m-7.5-13.5v13.5"
|
count=Signal::derive(inactive_count)
|
||||||
label="Durdurulanlar"
|
|
||||||
count=Signal::derive(paused_count)
|
|
||||||
/>
|
|
||||||
<SidebarItem
|
|
||||||
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="Pasif"
|
|
||||||
count=Signal::derive(inactive_count)
|
|
||||||
/>
|
|
||||||
</SidenavMenu>
|
|
||||||
</SidenavGroupContent>
|
|
||||||
</SidenavGroup>
|
|
||||||
</SidenavContent>
|
|
||||||
|
|
||||||
<SidenavFooter>
|
|
||||||
<div class="flex flex-col gap-4 p-4">
|
|
||||||
// Push Notification Toggle
|
|
||||||
<div class="flex items-center justify-between px-2 py-1 bg-muted/20 rounded-md border border-border/50">
|
|
||||||
<div class="flex flex-col gap-0.5">
|
|
||||||
<span class="text-[10px] font-bold uppercase tracking-wider text-foreground/70">"Bildirimler"</span>
|
|
||||||
<span class="text-[9px] text-muted-foreground">"Web Push"</span>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked=Signal::from(store.push_enabled)
|
|
||||||
on_checked_change=Callback::new(on_push_toggle)
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
|
// Separator
|
||||||
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0 border border-primary-foreground/10">
|
<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
|
||||||
|
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0">
|
||||||
{first_letter}
|
{first_letter}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden">
|
||||||
<div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div>
|
<div class="font-medium text-sm truncate text-foreground">{username}</div>
|
||||||
<div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div>
|
<div class="text-[10px] text-muted-foreground truncate">"Online"</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-1">
|
// Theme toggle button
|
||||||
<ThemeToggle />
|
<button
|
||||||
|
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"
|
||||||
<Button
|
on:click=toggle_theme
|
||||||
variant=ButtonVariant::Ghost
|
>
|
||||||
size=ButtonSize::Icon
|
<Show when=move || current_theme.get() == "dark" fallback=|| view! {
|
||||||
class="size-7 text-destructive hover:bg-destructive/10"
|
<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">
|
||||||
on:click=move |_| {
|
<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" />
|
||||||
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="size-4">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
}>
|
||||||
</div>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
// Logout button
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent text-destructive transition-colors"
|
||||||
|
on:click=move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
if shared::server_fns::auth::logout().await.is_ok() {
|
||||||
|
let window = web_sys::window().expect("window should exist");
|
||||||
|
let _ = window.location().set_href("/login");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SidenavFooter>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn SidebarItem(
|
fn SidebarButton(
|
||||||
active: Signal<bool>,
|
active: Signal<bool>,
|
||||||
on_click: impl Fn(web_sys::MouseEvent) + 'static + Send,
|
on_click: impl Fn(web_sys::MouseEvent) + 'static,
|
||||||
#[prop(into)] icon: String,
|
#[prop(into)] icon: String,
|
||||||
#[prop(into)] label: &'static str,
|
#[prop(into)] label: &'static str,
|
||||||
count: Signal<usize>,
|
count: Signal<usize>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let variant = move || if active.get() { SidenavMenuButtonVariant::Outline } else { SidenavMenuButtonVariant::Default };
|
|
||||||
let class = move || if active.get() { "bg-accent/50 border-accent text-foreground".to_string() } else { "text-muted-foreground hover:text-foreground".to_string() };
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<SidenavMenuItem>
|
<button
|
||||||
<SidenavMenuButton
|
class=move || if active.get() {
|
||||||
variant=Signal::derive(variant)
|
"inline-flex items-center justify-start gap-2 w-full h-8 rounded-md px-3 text-sm font-medium bg-secondary text-secondary-foreground transition-colors"
|
||||||
class=Signal::derive(class)
|
} else {
|
||||||
on:click=on_click
|
"inline-flex items-center justify-start gap-2 w-full h-8 rounded-md px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
>
|
}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4 shrink-0">
|
on:click=on_click
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d=icon.clone() />
|
>
|
||||||
</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">
|
||||||
<span class="flex-1 truncate">{label}</span>
|
<path stroke-linecap="round" stroke-linejoin="round" d=icon.clone() />
|
||||||
<span class="text-[10px] font-mono opacity-50">{count}</span>
|
</svg>
|
||||||
</SidenavMenuButton>
|
{label}
|
||||||
</SidenavMenuItem>
|
<span class="ml-auto text-xs font-mono opacity-70">{count}</span>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
201
frontend/src/components/layout/statusbar.rs
Normal file
201
frontend/src/components/layout/statusbar.rs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos::html;
|
||||||
|
use shared::GlobalLimitRequest;
|
||||||
|
use crate::api;
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn StatusBar() -> impl IntoView {
|
||||||
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
|
let stats = store.global_stats;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Preset limits in bytes/s
|
||||||
|
let limits: Vec<(i64, &str)> = vec!(
|
||||||
|
(0, "Unlimited"),
|
||||||
|
(100 * 1024, "100 KB/s"),
|
||||||
|
(500 * 1024, "500 KB/s"),
|
||||||
|
(1024 * 1024, "1 MB/s"),
|
||||||
|
(2 * 1024 * 1024, "2 MB/s"),
|
||||||
|
(5 * 1024 * 1024, "5 MB/s"),
|
||||||
|
(10 * 1024 * 1024, "10 MB/s"),
|
||||||
|
(20 * 1024 * 1024, "20 MB/s"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let set_limit = move |limit_type: &str, val: i64| {
|
||||||
|
let limit_type = limit_type.to_string();
|
||||||
|
log::info!("Setting {} limit to {}", limit_type, val);
|
||||||
|
|
||||||
|
let req = if limit_type == "down" {
|
||||||
|
GlobalLimitRequest {
|
||||||
|
max_download_rate: Some(val),
|
||||||
|
max_upload_rate: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GlobalLimitRequest {
|
||||||
|
max_download_rate: None,
|
||||||
|
max_upload_rate: Some(val),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
if let Err(e) = api::settings::set_global_limits(&req).await {
|
||||||
|
log::error!("Failed to set limit: {:?}", e);
|
||||||
|
} else {
|
||||||
|
log::info!("Limit set successfully");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let down_details_ref = NodeRef::<html::Details>::new();
|
||||||
|
let up_details_ref = NodeRef::<html::Details>::new();
|
||||||
|
|
||||||
|
let close_details = move |node_ref: NodeRef<html::Details>| {
|
||||||
|
if let Some(el) = node_ref.get_untracked() {
|
||||||
|
el.set_open(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="fixed bottom-0 left-0 right-0 h-8 min-h-8 bg-muted border-t border-border flex items-center px-4 text-xs gap-4 text-muted-foreground z-[99] cursor-pointer">
|
||||||
|
|
||||||
|
// --- DOWNLOAD SPEED DROPDOWN ---
|
||||||
|
<details class="group relative" node_ref=down_details_ref>
|
||||||
|
<summary class="flex items-center gap-2 cursor-pointer hover:text-foreground transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-mono">{move || format_speed(stats.get().down_rate)}</span>
|
||||||
|
<Show when=move || { stats.get().down_limit.unwrap_or(0) > 0 } fallback=|| ()>
|
||||||
|
<span class="text-[10px] opacity-60">
|
||||||
|
{move || format!("(Limit: {})", format_speed(stats.get().down_limit.unwrap_or(0)))}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="absolute bottom-full left-0 mb-2 z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md hidden group-open:block animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2">
|
||||||
|
<ul class="w-full">
|
||||||
|
{
|
||||||
|
limits.clone().into_iter().map(|(val, label)| {
|
||||||
|
let is_active = move || {
|
||||||
|
let current = stats.get().down_limit.unwrap_or(0);
|
||||||
|
(current - val).abs() < 1024
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class=move || {
|
||||||
|
let base = "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent hover:text-accent-foreground";
|
||||||
|
if is_active() { format!("{} bg-accent text-accent-foreground font-medium", base) } else { base.to_string() }
|
||||||
|
}
|
||||||
|
on:click=move |_| {
|
||||||
|
set_limit("down", val);
|
||||||
|
close_details(down_details_ref);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<Show when=is_active fallback=|| ()>
|
||||||
|
<span>"✓"</span>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
// --- UPLOAD SPEED DROPDOWN ---
|
||||||
|
<details class="group relative" node_ref=up_details_ref>
|
||||||
|
<summary class="flex items-center gap-2 cursor-pointer hover:text-foreground transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-mono">{move || format_speed(stats.get().up_rate)}</span>
|
||||||
|
<Show when=move || { stats.get().up_limit.unwrap_or(0) > 0 } fallback=|| ()>
|
||||||
|
<span class="text-[10px] opacity-60">
|
||||||
|
{move || format!("(Limit: {})", format_speed(stats.get().up_limit.unwrap_or(0)))}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="absolute bottom-full left-0 mb-2 z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md hidden group-open:block animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2">
|
||||||
|
<ul class="w-full">
|
||||||
|
{
|
||||||
|
limits.clone().into_iter().map(|(val, label)| {
|
||||||
|
let is_active = move || {
|
||||||
|
let current = stats.get().up_limit.unwrap_or(0);
|
||||||
|
(current - val).abs() < 1024
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class=move || {
|
||||||
|
let base = "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent hover:text-accent-foreground";
|
||||||
|
if is_active() { format!("{} bg-accent text-accent-foreground font-medium", base) } else { base.to_string() }
|
||||||
|
}
|
||||||
|
on:click=move |_| {
|
||||||
|
set_limit("up", val);
|
||||||
|
close_details(up_details_ref);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<Show when=is_active fallback=|| ()>
|
||||||
|
<span>"✓"</span>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-7 w-7"
|
||||||
|
title="Settings & Notification Permissions"
|
||||||
|
on:click=move |_| {
|
||||||
|
// Request push notification permission
|
||||||
|
leptos::task::spawn_local(async {
|
||||||
|
// ... existing logic ...
|
||||||
|
crate::store::subscribe_to_push_notifications().await;
|
||||||
|
// ... existing logic ...
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.212 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 012.6-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,64 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use crate::components::ui::sidenav::{SidenavTrigger, SidenavState};
|
use crate::components::torrent::add_torrent::AddTorrentDialog;
|
||||||
use crate::components::torrent::add_torrent::AddTorrent;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Toolbar(
|
pub fn Toolbar() -> impl IntoView {
|
||||||
sidenav_state: RwSignal<SidenavState>
|
let show_add_modal = signal(false);
|
||||||
) -> impl IntoView {
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
let header_view = view! {
|
let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
|
||||||
<header class="h-14 border-b bg-background/95 backdrop-blur-sm flex items-center justify-between px-4 sticky top-0 z-30">
|
|
||||||
<div class="flex items-center gap-4">
|
let search_value = RwSignal::new(String::new());
|
||||||
<SidenavTrigger data_state=sidenav_state />
|
|
||||||
<div class="h-4 w-px bg-border hidden md:block"></div>
|
// Sync search_value to store
|
||||||
<h2 class="text-sm font-semibold tracking-tight">"Torrents"</h2>
|
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
|
||||||
|
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
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
// Sağ kısım: Search kutusu
|
||||||
<AddTorrent />
|
<div class="flex flex-1 items-center justify-end gap-2">
|
||||||
|
<div class="hidden md:flex items-center gap-2 w-full max-w-xs">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search..."
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
};
|
<Show when=move || show_add_modal.0.get()>
|
||||||
|
<AddTorrentDialog on_close=Callback::new(move |()| show_add_modal.1.set(false)) />
|
||||||
header_view
|
</Show>
|
||||||
}
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
pub mod hooks;
|
|
||||||
pub mod context_menu;
|
pub mod context_menu;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod torrent;
|
pub mod torrent;
|
||||||
|
|||||||
@@ -1,31 +1,15 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
use crate::components::ui::input::{Input, InputType};
|
use crate::components::ui::input::{Input, InputType};
|
||||||
|
use crate::store::TorrentStore;
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::components::ui::button::{Button, ButtonVariant};
|
|
||||||
use crate::components::ui::dialog::{
|
|
||||||
Dialog, DialogTrigger, DialogContent, DialogBody, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose
|
|
||||||
};
|
|
||||||
use icons::Play;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AddTorrent() -> impl IntoView {
|
pub fn AddTorrentDialog(
|
||||||
view! {
|
on_close: Callback<()>,
|
||||||
<Dialog>
|
) -> impl IntoView {
|
||||||
<DialogTrigger variant=ButtonVariant::Default class="gap-2">
|
let _store = use_context::<TorrentStore>().expect("TorrentStore not provided");
|
||||||
<Play class="size-4" />
|
|
||||||
<span class="hidden sm:inline">"Add Torrent"</span>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent id="add-torrent-dialog">
|
|
||||||
<AddTorrentDialogContent />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn AddTorrentDialogContent() -> impl IntoView {
|
|
||||||
let uri = RwSignal::new(String::new());
|
let uri = RwSignal::new(String::new());
|
||||||
let is_loading = signal(false);
|
let is_loading = signal(false);
|
||||||
let error_msg = signal(Option::<String>::None);
|
let error_msg = signal(Option::<String>::None);
|
||||||
@@ -35,30 +19,20 @@ pub fn AddTorrentDialogContent() -> impl IntoView {
|
|||||||
let uri_val = uri.get();
|
let uri_val = uri.get();
|
||||||
|
|
||||||
if uri_val.is_empty() {
|
if uri_val.is_empty() {
|
||||||
error_msg.1.set(Some("Lütfen bir Magnet URI veya URL girin".to_string()));
|
error_msg.1.set(Some("Please enter a Magnet URI or URL".to_string()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
is_loading.1.set(true);
|
is_loading.1.set(true);
|
||||||
error_msg.1.set(None);
|
error_msg.1.set(None);
|
||||||
|
|
||||||
|
let on_close = on_close.clone();
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::torrent::add(&uri_val).await {
|
match api::torrent::add(&uri_val).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::info!("Torrent added successfully");
|
log::info!("Torrent added successfully");
|
||||||
crate::store::toast_success("Torrent başarıyla eklendi");
|
crate::store::toast_success("Torrent başarıyla eklendi");
|
||||||
|
on_close.run(());
|
||||||
// Programmatically close the dialog by triggering the close button
|
|
||||||
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
|
|
||||||
if let Some(el) = doc.get_element_by_id("add-torrent-dialog") {
|
|
||||||
if let Some(close_btn) = el.query_selector("[data-dialog-close]").ok().flatten() {
|
|
||||||
let _ = close_btn.dyn_into::<web_sys::HtmlElement>().map(|btn| btn.click());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uri.set(String::new());
|
|
||||||
is_loading.1.set(false);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to add torrent: {:?}", e);
|
log::error!("Failed to add torrent: {:?}", e);
|
||||||
@@ -69,16 +43,29 @@ pub fn AddTorrentDialogContent() -> impl IntoView {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let handle_backdrop = {
|
||||||
|
let on_close = on_close.clone();
|
||||||
|
move |e: web_sys::MouseEvent| {
|
||||||
|
e.stop_propagation();
|
||||||
|
on_close.run(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<DialogBody>
|
// Backdrop overlay
|
||||||
<DialogHeader>
|
<div
|
||||||
<DialogTitle>"Add Torrent"</DialogTitle>
|
class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
<DialogDescription>
|
on:click=handle_backdrop
|
||||||
"Enter a Magnet link or a .torrent file URL."
|
/>
|
||||||
</DialogDescription>
|
// Dialog panel
|
||||||
</DialogHeader>
|
<div class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-lg sm:max-w-[425px]">
|
||||||
|
// Header
|
||||||
|
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
|
||||||
|
<h2 class="text-lg font-semibold leading-none tracking-tight">"Add Torrent"</h2>
|
||||||
|
<p class="text-sm text-muted-foreground">"Enter a Magnet link or a .torrent file URL."</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form on:submit=handle_submit class="space-y-4 pt-4">
|
<form on:submit=handle_submit class="space-y-4">
|
||||||
<Input
|
<Input
|
||||||
r#type=InputType::Text
|
r#type=InputType::Text
|
||||||
placeholder="magnet:?xt=urn:btih:..."
|
placeholder="magnet:?xt=urn:btih:..."
|
||||||
@@ -92,13 +79,18 @@ pub fn AddTorrentDialogContent() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<DialogFooter class="pt-2">
|
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
||||||
<DialogClose>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center h-9 px-4 py-2 rounded-md text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
|
on:click=move |_| on_close.run(())
|
||||||
|
>
|
||||||
"Cancel"
|
"Cancel"
|
||||||
</DialogClose>
|
</button>
|
||||||
<Button
|
<button
|
||||||
attr:r#type="submit"
|
type="submit"
|
||||||
attr:disabled=move || is_loading.0.get()
|
class="inline-flex items-center justify-center h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
disabled=move || is_loading.0.get()
|
||||||
>
|
>
|
||||||
{move || if is_loading.0.get() {
|
{move || if is_loading.0.get() {
|
||||||
leptos::either::Either::Left(view! {
|
leptos::either::Either::Left(view! {
|
||||||
@@ -108,9 +100,21 @@ pub fn AddTorrentDialogContent() -> impl IntoView {
|
|||||||
} else {
|
} else {
|
||||||
leptos::either::Either::Right(view! { "Add" })
|
leptos::either::Either::Right(view! { "Add" })
|
||||||
}}
|
}}
|
||||||
</Button>
|
</button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogBody>
|
|
||||||
|
// Close button (X)
|
||||||
|
<button
|
||||||
|
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none"
|
||||||
|
on:click=move |_| on_close.run(())
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
|
||||||
|
<path d="M18 6 6 18"></path>
|
||||||
|
<path d="m6 6 12 12"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">"Close"</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,43 +1,10 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use icons::{ArrowUpDown, Inbox, Settings2, Play, Square, Trash2, Ellipsis, ArrowUp, ArrowDown, Check, ListFilter};
|
|
||||||
use crate::store::{get_action_messages, show_toast};
|
use crate::store::{get_action_messages, show_toast};
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use shared::NotificationLevel;
|
use shared::NotificationLevel;
|
||||||
use crate::components::context_menu::TorrentContextMenu;
|
use crate::components::context_menu::TorrentContextMenu;
|
||||||
use crate::components::ui::data_table::*;
|
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent};
|
||||||
use crate::components::ui::checkbox::Checkbox;
|
|
||||||
use crate::components::ui::badge::{Badge, BadgeVariant};
|
|
||||||
use crate::components::ui::button::{Button, ButtonVariant};
|
|
||||||
use crate::components::ui::empty::*;
|
|
||||||
use crate::components::ui::input::Input;
|
|
||||||
use crate::components::ui::multi_select::*;
|
|
||||||
use crate::components::ui::dropdown_menu::*;
|
|
||||||
use crate::components::ui::alert_dialog::{
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogBody,
|
|
||||||
AlertDialogClose,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
};
|
|
||||||
use tailwind_fuse::tw_merge;
|
|
||||||
|
|
||||||
const ALL_COLUMNS: [(&str, &str); 8] = [
|
|
||||||
("Name", "Name"),
|
|
||||||
("Size", "Size"),
|
|
||||||
("Progress", "Progress"),
|
|
||||||
("Status", "Status"),
|
|
||||||
("DownSpeed", "DL Speed"),
|
|
||||||
("UpSpeed", "UP Speed"),
|
|
||||||
("ETA", "ETA"),
|
|
||||||
("AddedDate", "Date"),
|
|
||||||
];
|
|
||||||
|
|
||||||
fn format_bytes(bytes: i64) -> String {
|
fn format_bytes(bytes: i64) -> String {
|
||||||
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
@@ -82,16 +49,8 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
let sort_col = signal(SortColumn::AddedDate);
|
let sort_col = signal(SortColumn::AddedDate);
|
||||||
let sort_dir = signal(SortDirection::Descending);
|
let sort_dir = signal(SortDirection::Descending);
|
||||||
|
|
||||||
let selected_hashes = RwSignal::new(HashSet::<String>::new());
|
|
||||||
|
|
||||||
let visible_columns = RwSignal::new(HashSet::from([
|
|
||||||
"Name".to_string(), "Size".to_string(), "Progress".to_string(),
|
|
||||||
"Status".to_string(), "DownSpeed".to_string(), "UpSpeed".to_string(),
|
|
||||||
"ETA".to_string(), "AddedDate".to_string()
|
|
||||||
]));
|
|
||||||
|
|
||||||
let sorted_hashes_data = Memo::new(move |_| {
|
let filtered_hashes = Memo::new(move |_| {
|
||||||
let torrents_map = store.torrents.get();
|
let torrents_map = store.torrents.get();
|
||||||
let filter = store.filter.get();
|
let filter = store.filter.get();
|
||||||
let search = store.search_query.get();
|
let search = store.search_query.get();
|
||||||
@@ -130,30 +89,7 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
};
|
};
|
||||||
if dir == SortDirection::Descending { cmp.reverse() } else { cmp }
|
if dir == SortDirection::Descending { cmp.reverse() } else { cmp }
|
||||||
});
|
});
|
||||||
torrents
|
torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
|
||||||
});
|
|
||||||
|
|
||||||
let filtered_hashes = Memo::new(move |_| {
|
|
||||||
sorted_hashes_data.get().into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
|
|
||||||
});
|
|
||||||
|
|
||||||
let selected_count = Signal::derive(move || {
|
|
||||||
let current_hashes: HashSet<String> = filtered_hashes.get().into_iter().collect();
|
|
||||||
selected_hashes.with(|selected| {
|
|
||||||
selected.iter().filter(|h| current_hashes.contains(*h)).count()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let has_selection = Signal::derive(move || selected_count.get() > 0);
|
|
||||||
|
|
||||||
let handle_select_all = Callback::new(move |checked: bool| {
|
|
||||||
selected_hashes.update(|selected| {
|
|
||||||
let hashes = filtered_hashes.get_untracked();
|
|
||||||
for h in hashes {
|
|
||||||
if checked { selected.insert(h); }
|
|
||||||
else { selected.remove(&h); }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let handle_sort = move |col: SortColumn| {
|
let handle_sort = move |col: SortColumn| {
|
||||||
@@ -167,35 +103,13 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let sort_icon = move |col: SortColumn| {
|
let sort_arrow = move |col: SortColumn| {
|
||||||
let is_active = sort_col.0.get() == col;
|
if sort_col.0.get() == col {
|
||||||
let class = if is_active { "size-3 text-primary" } else { "size-3 opacity-30 group-hover:opacity-100 transition-opacity" };
|
match sort_dir.0.get() {
|
||||||
view! { <ArrowUpDown class=class.to_string() /> }.into_any()
|
SortDirection::Ascending => view! { <span class="ml-1 text-xs">"▲"</span> }.into_any(),
|
||||||
};
|
SortDirection::Descending => view! { <span class="ml-1 text-xs">"▼"</span> }.into_any(),
|
||||||
|
|
||||||
let bulk_action = move |action: &'static str| {
|
|
||||||
let hashes: Vec<String> = selected_hashes.get().into_iter().collect();
|
|
||||||
if hashes.is_empty() { return; }
|
|
||||||
|
|
||||||
spawn_local(async move {
|
|
||||||
let mut success = true;
|
|
||||||
for hash in hashes {
|
|
||||||
let res = match action {
|
|
||||||
"start" => api::torrent::start(&hash).await,
|
|
||||||
"stop" => api::torrent::stop(&hash).await,
|
|
||||||
"delete" => api::torrent::delete(&hash).await,
|
|
||||||
"delete_with_data" => api::torrent::delete_with_data(&hash).await,
|
|
||||||
_ => Ok(()),
|
|
||||||
};
|
|
||||||
if res.is_err() { success = false; }
|
|
||||||
}
|
}
|
||||||
if success {
|
} else { view! { <span class="ml-1 text-xs opacity-0 group-hover:opacity-50">"▲"</span> }.into_any() }
|
||||||
show_toast(NotificationLevel::Success, format!("Toplu işlem başarıyla tamamlandı: {}", action));
|
|
||||||
selected_hashes.update(|s| s.clear());
|
|
||||||
} else {
|
|
||||||
show_toast(NotificationLevel::Error, "Bazı işlemler başarısız oldu.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_action = Callback::new(move |(action, hash): (String, String)| {
|
let on_action = Callback::new(move |(action, hash): (String, String)| {
|
||||||
@@ -218,352 +132,78 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="h-full bg-background relative flex flex-col overflow-hidden px-4 py-4 gap-4">
|
<div class="h-full bg-background relative flex flex-col overflow-hidden">
|
||||||
// --- TOPBAR ---
|
// --- DESKTOP VIEW ---
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="hidden md:flex flex-col h-full overflow-hidden">
|
||||||
<div class="flex items-center gap-2 flex-1 max-w-md">
|
// Header
|
||||||
<Input
|
<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">
|
||||||
class="h-9"
|
<div class="flex-1 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Name)>
|
||||||
placeholder="Torrent ara..."
|
"Name" {move || sort_arrow(SortColumn::Name)}
|
||||||
bind_value=store.search_query
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Show when=move || has_selection.get()>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger class="w-[140px] h-9 gap-2">
|
|
||||||
<Ellipsis class="size-4" />
|
|
||||||
{move || format!("Toplu İşlem ({})", selected_count.get())}
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent class="w-48">
|
|
||||||
<DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel>
|
|
||||||
<DropdownMenuGroup class="mt-2">
|
|
||||||
<DropdownMenuItem on:click=move |_| bulk_action("start")>
|
|
||||||
<Play class="mr-2 size-4" /> "Başlat"
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem on:click=move |_| bulk_action("stop")>
|
|
||||||
<Square class="mr-2 size-4" /> "Durdur"
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<div class="my-1 h-px bg-border" />
|
|
||||||
|
|
||||||
// Trigger the hidden AlertDialog from this menu item
|
|
||||||
<DropdownMenuItem class="text-destructive focus:bg-destructive/10 cursor-pointer" on:click=move |_| {
|
|
||||||
if let Some(trigger) = document().get_element_by_id("bulk-delete-trigger") {
|
|
||||||
let _ = trigger.dyn_into::<web_sys::HtmlElement>().map(|el: web_sys::HtmlElement| el.click());
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
<Trash2 class="mr-2 size-4" /> "Toplu Sil..."
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
// Hidden AlertDialog moved outside the DropdownMenuContent to ensure proper centering
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger attr:id="bulk-delete-trigger" class="hidden">""</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent class="sm:max-w-[425px]">
|
|
||||||
<AlertDialogBody>
|
|
||||||
<AlertDialogHeader class="space-y-3">
|
|
||||||
<AlertDialogTitle class="text-destructive flex items-center gap-2 text-xl">
|
|
||||||
<Trash2 class="size-6" />
|
|
||||||
"Toplu Silme Onayı"
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription class="text-sm leading-relaxed text-left">
|
|
||||||
{move || format!("Seçili {} adet torrent silinecek. Lütfen silme yöntemini seçin:", selected_count.get())}
|
|
||||||
<div class="mt-4 p-4 bg-destructive/5 rounded-lg border border-destructive/10 text-xs text-destructive/80 font-medium">
|
|
||||||
"⚠️ Dikkat: Verilerle birlikte silme işlemi dosyaları diskten de kalıcı olarak kaldıracaktır."
|
|
||||||
</div>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter class="mt-6">
|
|
||||||
<div class="flex flex-col-reverse sm:flex-row gap-3 w-full sm:justify-end">
|
|
||||||
<AlertDialogClose class="sm:flex-1 md:flex-none">"Vazgeç"</AlertDialogClose>
|
|
||||||
<div class="flex flex-col sm:flex-row gap-2">
|
|
||||||
<Button
|
|
||||||
variant=ButtonVariant::Secondary
|
|
||||||
class="w-full sm:w-auto font-medium"
|
|
||||||
on:click=move |_| bulk_action("delete")
|
|
||||||
>
|
|
||||||
"Sadece Sil"
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant=ButtonVariant::Destructive
|
|
||||||
class="w-full sm:w-auto font-bold"
|
|
||||||
on:click=move |_| bulk_action("delete_with_data")
|
|
||||||
>
|
|
||||||
"Verilerle Sil"
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogBody>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
// Mobile Sort Menu
|
|
||||||
<div class="block md:hidden">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger class="w-[100px] h-9 gap-2 text-xs">
|
|
||||||
<ListFilter class="size-4" />
|
|
||||||
"Sırala"
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent class="w-56">
|
|
||||||
<DropdownMenuLabel>"Sıralama Ölçütü"</DropdownMenuLabel>
|
|
||||||
<DropdownMenuGroup class="mt-2">
|
|
||||||
{move || {
|
|
||||||
let current_col = sort_col.0.get();
|
|
||||||
let current_dir = sort_dir.0.get();
|
|
||||||
|
|
||||||
let sort_items = vec![
|
|
||||||
(SortColumn::Name, "İsim"),
|
|
||||||
(SortColumn::Size, "Boyut"),
|
|
||||||
(SortColumn::Progress, "İlerleme"),
|
|
||||||
(SortColumn::Status, "Durum"),
|
|
||||||
(SortColumn::DownSpeed, "DL Hızı"),
|
|
||||||
(SortColumn::UpSpeed, "UP Hızı"),
|
|
||||||
(SortColumn::ETA, "Kalan Süre"),
|
|
||||||
(SortColumn::AddedDate, "Tarih"),
|
|
||||||
];
|
|
||||||
|
|
||||||
sort_items.into_iter().map(|(col, label)| {
|
|
||||||
let is_active = current_col == col;
|
|
||||||
view! {
|
|
||||||
<DropdownMenuItem on:click=move |_| handle_sort(col)>
|
|
||||||
<div class="flex items-center justify-between w-full">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{if is_active { view! { <Check class="size-4 text-primary" /> }.into_any() } else { view! { <div class="size-4" /> }.into_any() }}
|
|
||||||
<span class=if is_active { "font-bold text-primary" } else { "" }>{label}</span>
|
|
||||||
</div>
|
|
||||||
{if is_active {
|
|
||||||
match current_dir {
|
|
||||||
SortDirection::Ascending => view! { <ArrowUp class="size-3 opacity-50" /> }.into_any(),
|
|
||||||
SortDirection::Descending => view! { <ArrowDown class="size-3 opacity-50" /> }.into_any(),
|
|
||||||
}
|
|
||||||
} else { view! { "" }.into_any() }}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}.into_any()
|
|
||||||
}).collect_view()
|
|
||||||
}}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</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)>
|
||||||
// Desktop Columns Menu
|
"Size" {move || sort_arrow(SortColumn::Size)}
|
||||||
<div class="hidden md:flex">
|
</div>
|
||||||
<MultiSelect values=visible_columns>
|
<div class="w-48 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Progress)>
|
||||||
<MultiSelectTrigger class="w-[140px] h-9">
|
"Progress" {move || sort_arrow(SortColumn::Progress)}
|
||||||
<div class="flex items-center gap-2 text-xs">
|
</div>
|
||||||
<Settings2 class="size-4" />
|
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Status)>
|
||||||
"Sütunlar"
|
"Status" {move || sort_arrow(SortColumn::Status)}
|
||||||
</div>
|
</div>
|
||||||
</MultiSelectTrigger>
|
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
||||||
<MultiSelectContent>
|
"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}
|
||||||
<MultiSelectGroup>
|
</div>
|
||||||
{ALL_COLUMNS.into_iter().map(|(id, label)| {
|
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
||||||
let id_val = id.to_string();
|
"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}
|
||||||
view! {
|
</div>
|
||||||
<MultiSelectItem>
|
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::ETA)>
|
||||||
<MultiSelectOption value=id_val.clone() attr:disabled=move || id_val == "Name">
|
"ETA" {move || sort_arrow(SortColumn::ETA)}
|
||||||
{label}
|
</div>
|
||||||
</MultiSelectOption>
|
<div class="w-32 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
||||||
</MultiSelectItem>
|
"Date" {move || sort_arrow(SortColumn::AddedDate)}
|
||||||
}.into_any()
|
|
||||||
}).collect_view()}
|
|
||||||
</MultiSelectGroup>
|
|
||||||
</MultiSelectContent>
|
|
||||||
</MultiSelect>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
// --- MAIN CONTENT ---
|
// Regular List
|
||||||
<div class="flex-1 min-h-0 overflow-hidden">
|
<div class="flex-1 overflow-y-auto min-h-0">
|
||||||
// Desktop Table View
|
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
||||||
<DataTableWrapper class="hidden md:block h-full bg-card/50">
|
let on_action = on_action.clone();
|
||||||
<div class="h-full overflow-auto">
|
move |hash| {
|
||||||
<DataTable>
|
let h = hash.clone();
|
||||||
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
view! {
|
||||||
<DataTableRow class="hover:bg-transparent">
|
<TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
|
||||||
<DataTableHead class="w-12 px-4">
|
<TorrentRow hash=hash.clone() />
|
||||||
<Checkbox
|
</TorrentContextMenu>
|
||||||
checked=Signal::derive(move || {
|
|
||||||
let hashes = filtered_hashes.get();
|
|
||||||
!hashes.is_empty() && selected_count.get() == hashes.len()
|
|
||||||
})
|
|
||||||
on_checked_change=handle_select_all
|
|
||||||
/>
|
|
||||||
</DataTableHead>
|
|
||||||
|
|
||||||
{move || visible_columns.get().contains("Name").then(|| view! {
|
|
||||||
<DataTableHead class="cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground" on:click=move |_| handle_sort(SortColumn::Name)>
|
|
||||||
<div class="flex items-center gap-2">"Name" {move || sort_icon(SortColumn::Name)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
}).into_any()}
|
|
||||||
|
|
||||||
{move || visible_columns.get().contains("Size").then(|| view! {
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground" on:click=move |_| handle_sort(SortColumn::Size)>
|
|
||||||
<div class="flex items-center gap-2">"Size" {move || sort_icon(SortColumn::Size)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
}).into_any()}
|
|
||||||
|
|
||||||
{move || visible_columns.get().contains("Progress").then(|| view! {
|
|
||||||
<DataTableHead class="w-48 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground" on:click=move |_| handle_sort(SortColumn::Progress)>
|
|
||||||
<div class="flex items-center gap-2">"Progress" {move || sort_icon(SortColumn::Progress)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
}).into_any()}
|
|
||||||
|
|
||||||
{move || visible_columns.get().contains("Status").then(|| view! {
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground" on:click=move |_| handle_sort(SortColumn::Status)>
|
|
||||||
<div class="flex items-center gap-2">"Status" {move || sort_icon(SortColumn::Status)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
}).into_any()}
|
|
||||||
|
|
||||||
{move || visible_columns.get().contains("DownSpeed").then(|| view! {
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground text-right" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
|
||||||
<div class="flex items-center justify-end gap-2">"DL Speed" {move || sort_icon(SortColumn::DownSpeed)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
}).into_any()}
|
|
||||||
|
|
||||||
{move || visible_columns.get().contains("UpSpeed").then(|| view! {
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground text-right" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
|
||||||
<div class="flex items-center justify-end gap-2">"UP Speed" {move || sort_icon(SortColumn::UpSpeed)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
}).into_any()}
|
|
||||||
|
|
||||||
{move || visible_columns.get().contains("ETA").then(|| view! {
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground text-right" on:click=move |_| handle_sort(SortColumn::ETA)>
|
|
||||||
<div class="flex items-center justify-end gap-2">"ETA" {move || sort_icon(SortColumn::ETA)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
}).into_any()}
|
|
||||||
|
|
||||||
{move || visible_columns.get().contains("AddedDate").then(|| view! {
|
|
||||||
<DataTableHead class="w-32 cursor-pointer group select-none transition-all duration-100 active:scale-[0.98] hover:bg-muted/30 hover:text-foreground text-right" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
|
||||||
<div class="flex items-center justify-end gap-2">"Date" {move || sort_icon(SortColumn::AddedDate)}</div>
|
|
||||||
</DataTableHead>
|
|
||||||
}).into_any()}
|
|
||||||
</DataTableRow>
|
|
||||||
</DataTableHeader>
|
|
||||||
<DataTableBody>
|
|
||||||
<Show
|
|
||||||
when=move || !filtered_hashes.get().is_empty()
|
|
||||||
fallback=move || view! {
|
|
||||||
<DataTableRow class="hover:bg-transparent">
|
|
||||||
<DataTableCell attr:colspan="10" class="h-[400px]">
|
|
||||||
<Empty class="h-full">
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyMedia variant=EmptyMediaVariant::Icon>
|
|
||||||
<Inbox class="size-10 text-muted-foreground" />
|
|
||||||
</EmptyMedia>
|
|
||||||
<EmptyTitle>"Torrent Bulunamadı"</EmptyTitle>
|
|
||||||
<EmptyDescription>
|
|
||||||
{move || {
|
|
||||||
let query = store.search_query.get();
|
|
||||||
if query.is_empty() { "Henüz torrent bulunmuyor.".to_string() }
|
|
||||||
else { "Arama kriterlerinize uygun sonuç bulunamadı.".to_string() }
|
|
||||||
}}
|
|
||||||
</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
</DataTableCell>
|
|
||||||
</DataTableRow>
|
|
||||||
}.into_any()
|
|
||||||
>
|
|
||||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
|
||||||
let on_action = on_action.clone();
|
|
||||||
move |hash| {
|
|
||||||
let h = hash.clone();
|
|
||||||
let is_selected = Signal::derive(move || {
|
|
||||||
selected_hashes.with(|selected| selected.contains(&h))
|
|
||||||
});
|
|
||||||
let h_for_change = hash.clone();
|
|
||||||
view! {
|
|
||||||
<TorrentRow
|
|
||||||
hash=hash.clone()
|
|
||||||
on_action=on_action.clone()
|
|
||||||
is_selected=is_selected
|
|
||||||
visible_columns=visible_columns
|
|
||||||
on_select=Callback::new(move |checked| {
|
|
||||||
selected_hashes.update(|selected| {
|
|
||||||
if checked { selected.insert(h_for_change.clone()); }
|
|
||||||
else { selected.remove(&h_for_change); }
|
|
||||||
});
|
|
||||||
})
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} />
|
|
||||||
</Show>
|
|
||||||
</DataTableBody>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
</DataTableWrapper>
|
|
||||||
|
|
||||||
// Mobile Card View
|
|
||||||
<div class="block md:hidden h-full overflow-y-auto space-y-4 pb-32 px-1">
|
|
||||||
<Show
|
|
||||||
when=move || !filtered_hashes.get().is_empty()
|
|
||||||
fallback=move || view! {
|
|
||||||
<div class="flex flex-col items-center justify-center h-64 opacity-50 text-muted-foreground">
|
|
||||||
<Inbox class="size-12 mb-2" />
|
|
||||||
<p>"Torrent Bulunamadı"</p>
|
|
||||||
</div>
|
|
||||||
}.into_any()
|
|
||||||
>
|
|
||||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
|
||||||
let on_action = on_action.clone();
|
|
||||||
move |hash| {
|
|
||||||
let h = hash.clone();
|
|
||||||
let is_selected = Signal::derive(move || {
|
|
||||||
selected_hashes.with(|selected| selected.contains(&h))
|
|
||||||
});
|
|
||||||
let h_for_change = hash.clone();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<TorrentCard
|
|
||||||
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); }
|
|
||||||
});
|
|
||||||
})
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} />
|
}
|
||||||
</Show>
|
} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between px-2 py-1.5 text-[10px] md:text-[11px] text-muted-foreground bg-muted/20 border rounded-md">
|
// --- MOBILE VIEW ---
|
||||||
<div class="flex gap-3 md:gap-4">
|
<div class="md:hidden flex flex-col h-full bg-muted/10 relative overflow-hidden">
|
||||||
<span>{move || format!("Toplam: {} torrent", filtered_hashes.get().len())}</span>
|
<div class="flex-1 overflow-y-auto p-3 min-h-0">
|
||||||
<Show when=move || has_selection.get()>
|
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
||||||
<span class="text-primary font-bold">{move || format!("{} seçili", selected_count.get())}</span>
|
let on_action = on_action.clone();
|
||||||
</Show>
|
move |hash| {
|
||||||
|
let h = hash.clone();
|
||||||
|
view! {
|
||||||
|
<div class="pb-3">
|
||||||
|
<TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
|
||||||
|
<TorrentCard hash=hash.clone() />
|
||||||
|
</TorrentContextMenu>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} />
|
||||||
</div>
|
</div>
|
||||||
<div class="opacity-50">"VibeTorrent v3"</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}.into_any()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn TorrentRow(
|
fn TorrentRow(
|
||||||
hash: String,
|
hash: String,
|
||||||
on_action: Callback<(String, String)>,
|
|
||||||
is_selected: Signal<bool>,
|
|
||||||
visible_columns: RwSignal<HashSet<String>>,
|
|
||||||
on_select: Callback<bool>,
|
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
let h = hash.clone();
|
let h = hash.clone();
|
||||||
@@ -574,125 +214,50 @@ fn TorrentRow(
|
|||||||
view! {
|
view! {
|
||||||
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
||||||
{
|
{
|
||||||
let on_action = on_action.clone();
|
|
||||||
move || {
|
move || {
|
||||||
let t = torrent.get().unwrap();
|
let t = torrent.get().unwrap();
|
||||||
let t_name = t.name.clone();
|
let t_name = t.name.clone();
|
||||||
|
let status_color = match t.status { shared::TorrentStatus::Seeding => "text-green-500", shared::TorrentStatus::Downloading => "text-blue-500", shared::TorrentStatus::Paused => "text-yellow-500", shared::TorrentStatus::Error => "text-red-500", _ => "text-muted-foreground" };
|
||||||
|
|
||||||
let is_active_selection = Memo::new(move |_| {
|
|
||||||
let selected = store.selected_torrent.get();
|
|
||||||
selected.as_deref() == Some(stored_hash.get_value().as_str())
|
|
||||||
});
|
|
||||||
|
|
||||||
let t_name_stored = StoredValue::new(t_name.clone());
|
|
||||||
let h_for_menu = stored_hash.get_value();
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<TorrentContextMenu torrent_hash=h_for_menu on_action=on_action.clone()>
|
<div
|
||||||
<DataTableRow
|
class=move || {
|
||||||
class="cursor-pointer group h-10"
|
let selected = store.selected_torrent.get();
|
||||||
attr:data-state=move || if is_selected.get() || is_active_selection.get() { "selected" } else { "" }
|
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
|
||||||
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
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"
|
||||||
<DataTableCell class="w-12 px-4">
|
} else {
|
||||||
<Checkbox
|
"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"
|
||||||
checked=is_selected
|
}
|
||||||
on_checked_change=on_select
|
}
|
||||||
/>
|
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||||
</DataTableCell>
|
>
|
||||||
|
<div class="flex-1 min-w-0 px-2 font-medium truncate" title=t_name.clone()>{t_name.clone()}</div>
|
||||||
{move || visible_columns.get().contains("Name").then({
|
<div class="w-24 px-2 font-mono text-xs text-muted-foreground">{format_bytes(t.size)}</div>
|
||||||
move || view! {
|
<div class="w-48 px-2">
|
||||||
<DataTableCell class="font-medium truncate max-w-[200px] lg:max-w-md" attr:title=t_name_stored.get_value()>
|
<div class="flex items-center gap-2">
|
||||||
{t_name_stored.get_value()}
|
<div class="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
</DataTableCell>
|
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
|
||||||
}
|
</div>
|
||||||
}).into_any()}
|
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>
|
||||||
|
</div>
|
||||||
{move || visible_columns.get().contains("Size").then({
|
</div>
|
||||||
let size_bytes = t.size;
|
<div class={format!("w-24 px-2 text-xs font-medium {}", status_color)}>{format!("{:?}", t.status)}</div>
|
||||||
move || {
|
<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>
|
||||||
let size_str = format_bytes(size_bytes);
|
<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>
|
||||||
view! { <DataTableCell class="font-mono text-xs text-muted-foreground whitespace-nowrap">{size_str}</DataTableCell> }
|
<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>
|
||||||
}).into_any()}
|
</div>
|
||||||
|
}
|
||||||
{move || visible_columns.get().contains("Progress").then({
|
|
||||||
let percent = t.percent_complete;
|
|
||||||
move || view! {
|
|
||||||
<DataTableCell>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden min-w-[80px]">
|
|
||||||
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", percent)></div>
|
|
||||||
</div>
|
|
||||||
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", percent)}</span>
|
|
||||||
</div>
|
|
||||||
</DataTableCell>
|
|
||||||
}
|
|
||||||
}).into_any()}
|
|
||||||
|
|
||||||
{move || visible_columns.get().contains("Status").then({
|
|
||||||
let status_text = format!("{:?}", t.status);
|
|
||||||
let variant = match t.status {
|
|
||||||
shared::TorrentStatus::Seeding => BadgeVariant::Success,
|
|
||||||
shared::TorrentStatus::Downloading => BadgeVariant::Info,
|
|
||||||
shared::TorrentStatus::Paused => BadgeVariant::Warning,
|
|
||||||
shared::TorrentStatus::Error => BadgeVariant::Destructive,
|
|
||||||
_ => BadgeVariant::Secondary,
|
|
||||||
};
|
|
||||||
move || view! {
|
|
||||||
<DataTableCell class="whitespace-nowrap">
|
|
||||||
<Badge variant=variant>{status_text.clone()}</Badge>
|
|
||||||
</DataTableCell>
|
|
||||||
}
|
|
||||||
}).into_any()}
|
|
||||||
|
|
||||||
{move || visible_columns.get().contains("DownSpeed").then({
|
|
||||||
let rate = t.down_rate;
|
|
||||||
move || {
|
|
||||||
let speed_str = format_speed(rate);
|
|
||||||
view! { <DataTableCell class="text-right font-mono text-xs text-green-600 dark:text-green-500 whitespace-nowrap">{speed_str}</DataTableCell> }
|
|
||||||
}
|
|
||||||
}).into_any()}
|
|
||||||
|
|
||||||
{move || visible_columns.get().contains("UpSpeed").then({
|
|
||||||
let rate = t.up_rate;
|
|
||||||
move || {
|
|
||||||
let speed_str = format_speed(rate);
|
|
||||||
view! { <DataTableCell class="text-right font-mono text-xs text-blue-600 dark:text-blue-500 whitespace-nowrap">{speed_str}</DataTableCell> }
|
|
||||||
}
|
|
||||||
}).into_any()}
|
|
||||||
|
|
||||||
{move || visible_columns.get().contains("ETA").then({
|
|
||||||
let eta = t.eta;
|
|
||||||
move || {
|
|
||||||
let eta_str = format_duration(eta);
|
|
||||||
view! { <DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">{eta_str}</DataTableCell> }
|
|
||||||
}
|
|
||||||
}).into_any()}
|
|
||||||
|
|
||||||
{move || visible_columns.get().contains("AddedDate").then({
|
|
||||||
let date = t.added_date;
|
|
||||||
move || {
|
|
||||||
let date_str = format_date(date);
|
|
||||||
view! { <DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">{date_str}</DataTableCell> }
|
|
||||||
}
|
|
||||||
}).into_any()}
|
|
||||||
</DataTableRow>
|
|
||||||
</TorrentContextMenu>
|
|
||||||
}.into_any()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</Show>
|
</Show>
|
||||||
}.into_any()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn TorrentCard(
|
fn TorrentCard(
|
||||||
hash: String,
|
hash: String,
|
||||||
on_action: Callback<(String, String)>,
|
|
||||||
is_selected: Signal<bool>,
|
|
||||||
on_select: Callback<bool>,
|
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
let h = hash.clone();
|
let h = hash.clone();
|
||||||
@@ -703,82 +268,53 @@ fn TorrentCard(
|
|||||||
view! {
|
view! {
|
||||||
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
||||||
{
|
{
|
||||||
let on_action = on_action.clone();
|
|
||||||
move || {
|
move || {
|
||||||
let t = torrent.get().unwrap();
|
let t = torrent.get().unwrap();
|
||||||
let t_name = t.name.clone();
|
let t_name = t.name.clone();
|
||||||
let status_variant = match t.status {
|
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" };
|
||||||
shared::TorrentStatus::Seeding => BadgeVariant::Success,
|
|
||||||
shared::TorrentStatus::Downloading => BadgeVariant::Info,
|
|
||||||
shared::TorrentStatus::Paused => BadgeVariant::Warning,
|
|
||||||
shared::TorrentStatus::Error => BadgeVariant::Destructive,
|
|
||||||
_ => BadgeVariant::Secondary
|
|
||||||
};
|
|
||||||
let h_for_menu = stored_hash.get_value();
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<TorrentContextMenu torrent_hash=h_for_menu on_action=on_action.clone()>
|
<div
|
||||||
<div
|
class=move || {
|
||||||
class=move || tw_merge!(
|
let selected = store.selected_torrent.get();
|
||||||
"rounded-lg transition-all duration-200 border cursor-pointer select-none overflow-hidden active:scale-[0.98]",
|
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
|
||||||
if is_selected.get() {
|
if is_selected {
|
||||||
"bg-primary/10 border-primary shadow-sm"
|
"ring-2 ring-primary rounded-lg transition-all"
|
||||||
} else {
|
} else {
|
||||||
"bg-card border-border hover:border-primary/50"
|
"transition-all"
|
||||||
}
|
|
||||||
)
|
|
||||||
on:click=move |_| {
|
|
||||||
let current = is_selected.get();
|
|
||||||
on_select.run(!current);
|
|
||||||
store.selected_torrent.set(Some(stored_hash.get_value()));
|
|
||||||
}
|
}
|
||||||
>
|
}
|
||||||
<div class="p-4 space-y-3">
|
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||||
<div class="flex justify-between items-start gap-3">
|
>
|
||||||
<div class="flex-1 min-w-0">
|
<Card class="h-full select-none cursor-pointer hover:border-primary transition-colors">
|
||||||
<h3 class="text-sm font-bold leading-tight line-clamp-2 break-all">{t_name.clone()}</h3>
|
<CardHeader class="p-3 pb-0">
|
||||||
</div>
|
<div class="flex justify-between items-start gap-2">
|
||||||
<Badge variant=status_variant class="uppercase tracking-wider text-[10px]">
|
<CardTitle class="text-sm font-medium leading-tight line-clamp-2">{t_name.clone()}</CardTitle>
|
||||||
{format!("{:?}", t.status)}
|
<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>
|
||||||
</Badge>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
<div class="space-y-1.5">
|
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
|
||||||
<div class="flex justify-between text-[10px] font-medium text-muted-foreground">
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<span class="opacity-70">"Boyut:"</span> {format_bytes(t.size)}
|
|
||||||
</span>
|
|
||||||
<span class="font-bold text-primary">{format!("{:.1}%", t.percent_complete)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
|
||||||
<div class="h-full bg-primary transition-all duration-500 ease-out" style=format!("width: {}%", t.percent_complete)></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-y-2 gap-x-4 text-[10px] font-mono pt-2 border-t border-border/40">
|
|
||||||
<div class="flex flex-col gap-0.5">
|
|
||||||
<span class="text-muted-foreground uppercase text-[8px] tracking-tighter">"İndirme"</span>
|
|
||||||
<span class="text-blue-600 dark:text-blue-400 font-bold">{format_speed(t.down_rate)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-0.5">
|
|
||||||
<span class="text-muted-foreground uppercase text-[8px] tracking-tighter">"Gönderme"</span>
|
|
||||||
<span class="text-green-600 dark:text-green-400 font-bold">{format_speed(t.up_rate)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-0.5">
|
|
||||||
<span class="text-muted-foreground uppercase text-[8px] tracking-tighter">"Kalan Süre"</span>
|
|
||||||
<span class="text-foreground font-medium">{format_duration(t.eta)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-0.5 items-end text-right">
|
|
||||||
<span class="text-muted-foreground uppercase text-[8px] tracking-tighter">"Eklenme"</span>
|
|
||||||
<span class="text-foreground/70">{format_date(t.added_date)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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">
|
||||||
</TorrentContextMenu>
|
<div class="flex flex-col text-blue-600 dark:text-blue-500"><span>"DL"</span><span>{format_speed(t.down_rate)}</span></div>
|
||||||
}.into_any()
|
<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>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</Show>
|
</Show>
|
||||||
}.into_any()
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use tw_merge::tw_merge;
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Accordion(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
let class = tw_merge!("w-full", class);
|
|
||||||
view! { <div class=class>{children()}</div> }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn AccordionItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
let class = tw_merge!("border-b", class);
|
|
||||||
view! { <div class=class>{children()}</div> }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn AccordionHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
let class = tw_merge!("flex", class);
|
|
||||||
view! { <div class=class>{children()}</div> }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn AccordionTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
let class = tw_merge!(
|
|
||||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
|
||||||
class
|
|
||||||
);
|
|
||||||
view! {
|
|
||||||
<button type="button" class=class>
|
|
||||||
{children()}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn AccordionContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
let class = tw_merge!("overflow-hidden text-sm transition-all", class);
|
|
||||||
view! { <div class=class>{children()}</div> }
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
|
|
||||||
use crate::components::ui::button::{ButtonSize, ButtonVariant};
|
|
||||||
use crate::components::ui::dialog::{
|
|
||||||
Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn AlertDialog(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
view! { <Dialog class=class>{children()}</Dialog> }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn AlertDialogTrigger(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
|
|
||||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
|
||||||
) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<DialogTrigger class=class variant=variant size=size>
|
|
||||||
{children()}
|
|
||||||
</DialogTrigger>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn AlertDialogContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<DialogContent class=class close_on_backdrop_click=false data_name_prefix="AlertDialog">
|
|
||||||
{children()}
|
|
||||||
</DialogContent>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn AlertDialogBody(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<DialogBody class=class attr:data-name="AlertDialogBody">
|
|
||||||
{children()}
|
|
||||||
</DialogBody>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn AlertDialogHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<DialogHeader class=class attr:data-name="AlertDialogHeader">
|
|
||||||
{children()}
|
|
||||||
</DialogHeader>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn AlertDialogTitle(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<DialogTitle class=class attr:data-name="AlertDialogTitle">
|
|
||||||
{children()}
|
|
||||||
</DialogTitle>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn AlertDialogDescription(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<DialogDescription class=class attr:data-name="AlertDialogDescription">
|
|
||||||
{children()}
|
|
||||||
</DialogDescription>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn AlertDialogFooter(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<DialogFooter class=class attr:data-name="AlertDialogFooter">
|
|
||||||
{children()}
|
|
||||||
</DialogFooter>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn AlertDialogClose(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
|
|
||||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
|
||||||
) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<DialogClose class=class variant=variant size=size>
|
|
||||||
{children()}
|
|
||||||
</DialogClose>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use tailwind_fuse::tw_merge;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
|
|
||||||
pub enum BadgeVariant {
|
|
||||||
#[default]
|
|
||||||
Default,
|
|
||||||
Secondary,
|
|
||||||
Outline,
|
|
||||||
Destructive,
|
|
||||||
Success,
|
|
||||||
Warning,
|
|
||||||
Info,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Badge(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, default = BadgeVariant::Default)] variant: BadgeVariant,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let variant_classes = match variant {
|
|
||||||
BadgeVariant::Default => "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
|
||||||
BadgeVariant::Secondary => "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
||||||
BadgeVariant::Outline => "text-foreground",
|
|
||||||
BadgeVariant::Destructive => "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
|
||||||
BadgeVariant::Success => "border-transparent bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
|
|
||||||
BadgeVariant::Warning => "border-transparent bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",
|
|
||||||
BadgeVariant::Info => "border-transparent bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
|
||||||
};
|
|
||||||
|
|
||||||
let class = tw_merge!(
|
|
||||||
"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",
|
|
||||||
variant_classes,
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div class=class>
|
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_ui::variants;
|
use leptos_ui::variants;
|
||||||
|
|
||||||
// TODO 💪 Loading state (demo_use_timeout_fn.rs and demo_button.rs)
|
|
||||||
|
|
||||||
variants! {
|
variants! {
|
||||||
Button {
|
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.
|
base: "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive w-fit hover:cursor-pointer active:scale-[0.98] active:opacity-100 touch-manipulation [-webkit-tap-highlight-color:transparent] select-none [-webkit-touch-callout:none]",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
Default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
Default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
@@ -13,21 +11,13 @@ variants! {
|
|||||||
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",
|
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",
|
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",
|
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",
|
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: {
|
size: {
|
||||||
Default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
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",
|
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",
|
Lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
Icon: "size-9",
|
Icon: "size-9",
|
||||||
//
|
|
||||||
Mobile: "px-6 py-3 rounded-[24px]",
|
|
||||||
Badge: "px-2.5 py-0.5 text-xs"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: {
|
component: {
|
||||||
@@ -36,4 +26,4 @@ variants! {
|
|||||||
support_aria_current: true
|
support_aria_current: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use tailwind_fuse::tw_merge;
|
|
||||||
use crate::components::ui::button::{Button, ButtonVariant};
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn ButtonAction(
|
|
||||||
children: Children,
|
|
||||||
#[prop(into)] on_action: Callback<()>,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(default = 1000)] hold_duration: u64,
|
|
||||||
#[prop(default = ButtonVariant::Default)] variant: ButtonVariant,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let is_holding = RwSignal::new(false);
|
|
||||||
let generation = StoredValue::new(0u64);
|
|
||||||
|
|
||||||
let on_down = move |_| {
|
|
||||||
generation.update_value(|g| *g += 1);
|
|
||||||
is_holding.set(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_up = move |_| is_holding.set(false);
|
|
||||||
|
|
||||||
Effect::new(move |_| {
|
|
||||||
if is_holding.get() {
|
|
||||||
let current_gen = generation.get_value();
|
|
||||||
leptos::task::spawn_local(async move {
|
|
||||||
gloo_timers::future::TimeoutFuture::new(hold_duration as u32).await;
|
|
||||||
// Double validation: Is user still holding AND is it the SAME hold attempt?
|
|
||||||
if is_holding.get_untracked() && generation.get_value() == current_gen {
|
|
||||||
on_action.run(());
|
|
||||||
is_holding.set(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let merged_class = move || tw_merge!(
|
|
||||||
"relative overflow-hidden transition-all active:scale-[0.98]",
|
|
||||||
class.clone()
|
|
||||||
);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<style>
|
|
||||||
"@keyframes button-hold-progress {
|
|
||||||
from { width: 0%; }
|
|
||||||
to { width: 100%; }
|
|
||||||
}
|
|
||||||
.animate-button-hold {
|
|
||||||
animation: button-hold-progress var(--button-hold-duration) linear forwards;
|
|
||||||
}"
|
|
||||||
</style>
|
|
||||||
<Button
|
|
||||||
variant=variant
|
|
||||||
class=merged_class()
|
|
||||||
attr:style=format!("--button-hold-duration: {}ms", hold_duration)
|
|
||||||
on:mousedown=on_down
|
|
||||||
on:mouseup=on_up
|
|
||||||
on:mouseleave=on_up
|
|
||||||
on:touchstart=move |_| is_holding.set(true)
|
|
||||||
on:touchend=move |_| is_holding.set(false)
|
|
||||||
>
|
|
||||||
// Progress Overlay
|
|
||||||
<Show when=move || is_holding.get()>
|
|
||||||
<div class="absolute inset-0 bg-white/20 dark:bg-black/20 pointer-events-none animate-button-hold" />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<span class="relative z-10 flex items-center justify-center gap-2">
|
|
||||||
{children()}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct ContextMenuContext {
|
|
||||||
target_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn ContextMenu(children: Children) -> impl IntoView {
|
|
||||||
let context_target_id = use_random_id_for("context");
|
|
||||||
|
|
||||||
let ctx = ContextMenuContext { target_id: context_target_id.clone() };
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Provider value=ctx>
|
|
||||||
<style>
|
|
||||||
"
|
|
||||||
/* Submenu Styles */
|
|
||||||
.context__menu_sub_content {
|
|
||||||
position: absolute;
|
|
||||||
inset-inline-start: calc(100% + 8px);
|
|
||||||
inset-block-start: -4px;
|
|
||||||
z-index: 100;
|
|
||||||
min-inline-size: 160px;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transform: translateX(-8px);
|
|
||||||
transition: all 0.2s ease-out;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context__menu_sub_trigger:hover .context__menu_sub_content {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: translateX(0);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
"
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div data-name="ContextMenu" class="contents">
|
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
</Provider>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wrapper that triggers the context menu on right-click.
|
|
||||||
/// The `on_open` callback is triggered when the context menu opens (right-click).
|
|
||||||
#[component]
|
|
||||||
pub fn ContextMenuTrigger(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(optional)] on_open: Option<Callback<()>>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<ContextMenuContext>();
|
|
||||||
let trigger_class = tw_merge!("contents", class);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div
|
|
||||||
class=trigger_class
|
|
||||||
data-name="ContextMenuTrigger"
|
|
||||||
data-context-trigger=ctx.target_id
|
|
||||||
on:contextmenu=move |_e: web_sys::MouseEvent| {
|
|
||||||
if let Some(cb) = on_open {
|
|
||||||
cb.run(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Content of the context menu that appears on right-click.
|
|
||||||
/// The `on_close` callback is triggered when the menu closes (click outside, ESC key, or action click).
|
|
||||||
#[component]
|
|
||||||
pub fn ContextMenuContent(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(optional)] on_close: Option<Callback<()>>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<ContextMenuContext>();
|
|
||||||
|
|
||||||
let base_classes = "fixed z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100";
|
|
||||||
|
|
||||||
let class = tw_merge!(base_classes, class);
|
|
||||||
|
|
||||||
let target_id_for_script = ctx.target_id.clone();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div
|
|
||||||
data-name="ContextMenuContent"
|
|
||||||
class=class
|
|
||||||
// Listen for custom 'contextmenuclose' event dispatched by JS when menu closes
|
|
||||||
on:contextmenuclose=move |_: web_sys::CustomEvent| {
|
|
||||||
if let Some(cb) = on_close {
|
|
||||||
cb.run(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
id=ctx.target_id
|
|
||||||
data-target="target__context"
|
|
||||||
data-state="closed"
|
|
||||||
style="pointer-events: none;"
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
{format!(
|
|
||||||
r#"
|
|
||||||
(function() {{
|
|
||||||
const setupContextMenu = () => {{
|
|
||||||
const menu = document.querySelector('#{}');
|
|
||||||
const trigger = document.querySelector('[data-context-trigger="{}"]');
|
|
||||||
|
|
||||||
if (!menu || !trigger) {{
|
|
||||||
setTimeout(setupContextMenu, 50);
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
|
|
||||||
if (menu.hasAttribute('data-initialized')) {{
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
menu.setAttribute('data-initialized', 'true');
|
|
||||||
|
|
||||||
let isOpen = false;
|
|
||||||
|
|
||||||
const updatePosition = (x, y) => {{
|
|
||||||
const menuRect = menu.getBoundingClientRect();
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
|
|
||||||
// Calculate position, ensuring menu stays within viewport
|
|
||||||
let left = x;
|
|
||||||
let top = y;
|
|
||||||
|
|
||||||
// Adjust if menu would go off right edge
|
|
||||||
if (x + menuRect.width > viewportWidth) {{
|
|
||||||
left = x - menuRect.width;
|
|
||||||
}}
|
|
||||||
|
|
||||||
// Adjust if menu would go off bottom edge
|
|
||||||
if (y + menuRect.height > viewportHeight) {{
|
|
||||||
top = y - menuRect.height;
|
|
||||||
}}
|
|
||||||
|
|
||||||
menu.style.left = `${{left}}px`;
|
|
||||||
menu.style.top = `${{top}}px`;
|
|
||||||
menu.style.transformOrigin = 'top left';
|
|
||||||
}};
|
|
||||||
|
|
||||||
const openMenu = (x, y) => {{
|
|
||||||
isOpen = true;
|
|
||||||
|
|
||||||
// Close any other open context menus
|
|
||||||
const allMenus = document.querySelectorAll('[data-target="target__context"]');
|
|
||||||
allMenus.forEach(m => {{
|
|
||||||
if (m !== menu && m.getAttribute('data-state') === 'open') {{
|
|
||||||
m.setAttribute('data-state', 'closed');
|
|
||||||
m.style.pointerEvents = 'none';
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
|
|
||||||
menu.setAttribute('data-state', 'open');
|
|
||||||
menu.style.visibility = 'hidden';
|
|
||||||
menu.style.pointerEvents = 'auto';
|
|
||||||
|
|
||||||
// Force reflow
|
|
||||||
menu.offsetHeight;
|
|
||||||
|
|
||||||
updatePosition(x, y);
|
|
||||||
menu.style.visibility = 'visible';
|
|
||||||
|
|
||||||
// Lock scroll
|
|
||||||
if (window.ScrollLock) {{
|
|
||||||
window.ScrollLock.lock();
|
|
||||||
}}
|
|
||||||
|
|
||||||
setTimeout(() => {{
|
|
||||||
document.addEventListener('click', handleClickOutside);
|
|
||||||
document.addEventListener('contextmenu', handleContextOutside);
|
|
||||||
}}, 0);
|
|
||||||
}};
|
|
||||||
|
|
||||||
const closeMenu = () => {{
|
|
||||||
isOpen = false;
|
|
||||||
menu.setAttribute('data-state', 'closed');
|
|
||||||
menu.style.pointerEvents = 'none';
|
|
||||||
document.removeEventListener('click', handleClickOutside);
|
|
||||||
document.removeEventListener('contextmenu', handleContextOutside);
|
|
||||||
|
|
||||||
// Dispatch custom event for Leptos to listen to
|
|
||||||
menu.dispatchEvent(new CustomEvent('contextmenuclose', {{ bubbles: false }}));
|
|
||||||
|
|
||||||
if (window.ScrollLock) {{
|
|
||||||
window.ScrollLock.unlock(200);
|
|
||||||
}}
|
|
||||||
}};
|
|
||||||
|
|
||||||
const handleClickOutside = (e) => {{
|
|
||||||
if (!menu.contains(e.target)) {{
|
|
||||||
closeMenu();
|
|
||||||
}}
|
|
||||||
}};
|
|
||||||
|
|
||||||
const handleContextOutside = (e) => {{
|
|
||||||
if (!trigger.contains(e.target)) {{
|
|
||||||
closeMenu();
|
|
||||||
}}
|
|
||||||
}};
|
|
||||||
|
|
||||||
// Right-click on trigger
|
|
||||||
trigger.addEventListener('contextmenu', (e) => {{
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (isOpen) {{
|
|
||||||
closeMenu();
|
|
||||||
}}
|
|
||||||
openMenu(e.clientX, e.clientY);
|
|
||||||
}});
|
|
||||||
|
|
||||||
// Close when action is clicked
|
|
||||||
const actions = menu.querySelectorAll('[data-context-close]');
|
|
||||||
actions.forEach(action => {{
|
|
||||||
action.addEventListener('click', () => {{
|
|
||||||
closeMenu();
|
|
||||||
}});
|
|
||||||
}});
|
|
||||||
|
|
||||||
// Handle ESC key
|
|
||||||
document.addEventListener('keydown', (e) => {{
|
|
||||||
if (e.key === 'Escape' && isOpen) {{
|
|
||||||
e.preventDefault();
|
|
||||||
closeMenu();
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
}};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {{
|
|
||||||
document.addEventListener('DOMContentLoaded', setupContextMenu);
|
|
||||||
}} else {{
|
|
||||||
setupContextMenu();
|
|
||||||
}}
|
|
||||||
}})();
|
|
||||||
"#,
|
|
||||||
target_id_for_script,
|
|
||||||
target_id_for_script,
|
|
||||||
)}
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// * 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,
|
|
||||||
};
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
use icons::X;
|
|
||||||
use leptos::context::Provider;
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use leptos_ui::clx;
|
|
||||||
use tw_merge::*;
|
|
||||||
|
|
||||||
use crate::components::hooks::use_random::use_random_id_for;
|
|
||||||
use crate::components::ui::button::{Button, ButtonSize, ButtonVariant};
|
|
||||||
|
|
||||||
mod components {
|
|
||||||
use super::*;
|
|
||||||
clx! {DialogBody, div, "flex flex-col gap-4"}
|
|
||||||
clx! {DialogHeader, div, "flex flex-col gap-2 text-center sm:text-left"}
|
|
||||||
clx! {DialogTitle, h3, "text-lg leading-none font-semibold"}
|
|
||||||
clx! {DialogDescription, p, "text-muted-foreground text-sm"}
|
|
||||||
clx! {DialogFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub use components::*;
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct DialogContext {
|
|
||||||
target_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Dialog(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
let dialog_target_id = use_random_id_for("dialog");
|
|
||||||
|
|
||||||
let ctx = DialogContext { target_id: dialog_target_id.clone() };
|
|
||||||
|
|
||||||
let merged_class = tw_merge!("w-fit", class);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Provider value=ctx>
|
|
||||||
<div class=merged_class data-name="__Dialog">
|
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
</Provider>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn DialogTrigger(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
|
|
||||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<DialogContext>();
|
|
||||||
let trigger_id = format!("trigger_{}", ctx.target_id);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Button
|
|
||||||
class=class
|
|
||||||
attr:id=trigger_id
|
|
||||||
attr:tabindex="0"
|
|
||||||
attr:data-dialog-trigger=ctx.target_id
|
|
||||||
variant=variant
|
|
||||||
size=size
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn DialogContent(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(optional, into)] id: Option<String>,
|
|
||||||
#[prop(into, optional)] hide_close_button: Option<bool>,
|
|
||||||
#[prop(default = true)] close_on_backdrop_click: bool,
|
|
||||||
#[prop(default = "Dialog")] data_name_prefix: &'static str,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<DialogContext>();
|
|
||||||
let merged_class = tw_merge!(
|
|
||||||
"relative bg-background border rounded-2xl shadow-lg p-6 w-full max-w-[calc(100%-2rem)] max-h-[85vh] fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-100 transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100",
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
let backdrop_data_name = format!("{}Backdrop", data_name_prefix);
|
|
||||||
let content_data_name = format!("{}Content", data_name_prefix);
|
|
||||||
|
|
||||||
let target_id_clone = ctx.target_id.clone();
|
|
||||||
let backdrop_id = format!("{}_backdrop", ctx.target_id);
|
|
||||||
let backdrop_id_for_script = backdrop_id.clone();
|
|
||||||
let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" };
|
|
||||||
|
|
||||||
// Use provided id or fallback to random target_id
|
|
||||||
let final_id = id.unwrap_or_else(|| ctx.target_id.clone());
|
|
||||||
let final_id_for_script = final_id.clone();
|
|
||||||
let trigger_id_for_script = ctx.target_id.clone();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<script src="/lock_scroll.js"></script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
data-name=backdrop_data_name
|
|
||||||
id=backdrop_id
|
|
||||||
class="fixed inset-0 transition-opacity duration-200 pointer-events-none z-60 bg-black/50 data-[state=closed]:opacity-0 data-[state=open]:opacity-100"
|
|
||||||
data-state="closed"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
data-name=content_data_name
|
|
||||||
class=merged_class
|
|
||||||
id=final_id
|
|
||||||
data-target="target__dialog"
|
|
||||||
data-state="closed"
|
|
||||||
data-backdrop=backdrop_behavior
|
|
||||||
style="pointer-events: none;"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class=format!(
|
|
||||||
"absolute top-4 right-4 p-1 rounded-sm focus:ring-2 focus:ring-offset-2 focus:outline-none [&_svg:not([class*='size-'])]:size-4 focus:ring-ring{}",
|
|
||||||
if hide_close_button.unwrap_or(false) { " hidden" } else { "" },
|
|
||||||
)
|
|
||||||
data-dialog-close=target_id_clone.clone()
|
|
||||||
aria-label="Close dialog"
|
|
||||||
>
|
|
||||||
<span class="hidden">"Close Dialog"</span>
|
|
||||||
<X />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
{format!(
|
|
||||||
r#"
|
|
||||||
(function() {{
|
|
||||||
const setupDialog = () => {{
|
|
||||||
const dialog = document.querySelector('#{}');
|
|
||||||
const backdrop = document.querySelector('#{}');
|
|
||||||
const trigger = document.querySelector('[data-dialog-trigger="{}"]');
|
|
||||||
|
|
||||||
if (!dialog || !backdrop || !trigger) {{
|
|
||||||
setTimeout(setupDialog, 50);
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
|
|
||||||
if (dialog.hasAttribute('data-initialized')) {{
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
dialog.setAttribute('data-initialized', 'true');
|
|
||||||
|
|
||||||
const openDialog = () => {{
|
|
||||||
if (window.ScrollLock) window.ScrollLock.lock();
|
|
||||||
dialog.setAttribute('data-state', 'open');
|
|
||||||
backdrop.setAttribute('data-state', 'open');
|
|
||||||
dialog.style.pointerEvents = 'auto';
|
|
||||||
backdrop.style.pointerEvents = 'auto';
|
|
||||||
}};
|
|
||||||
|
|
||||||
const closeDialog = () => {{
|
|
||||||
dialog.setAttribute('data-state', 'closed');
|
|
||||||
backdrop.setAttribute('data-state', 'closed');
|
|
||||||
dialog.style.pointerEvents = 'none';
|
|
||||||
backdrop.style.pointerEvents = 'none';
|
|
||||||
window.ScrollLock.unlock(200);
|
|
||||||
}};
|
|
||||||
|
|
||||||
trigger.addEventListener('click', openDialog);
|
|
||||||
dialog.querySelectorAll('[data-dialog-close]').forEach(btn => {{
|
|
||||||
btn.addEventListener('click', closeDialog);
|
|
||||||
}});
|
|
||||||
backdrop.addEventListener('click', () => {{
|
|
||||||
if (dialog.getAttribute('data-backdrop') === 'auto') {{
|
|
||||||
closeDialog();
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
document.addEventListener('keydown', (e) => {{
|
|
||||||
if (e.key === 'Escape' && dialog.getAttribute('data-state') === 'open') {{
|
|
||||||
e.preventDefault();
|
|
||||||
closeDialog();
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
}};
|
|
||||||
setupDialog();
|
|
||||||
}})();
|
|
||||||
"#,
|
|
||||||
final_id_for_script,
|
|
||||||
backdrop_id_for_script,
|
|
||||||
trigger_id_for_script,
|
|
||||||
)}
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn DialogClose(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
|
|
||||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<DialogContext>();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Button
|
|
||||||
class=class
|
|
||||||
attr:data-dialog-close=ctx.target_id
|
|
||||||
attr:aria-label="Close dialog"
|
|
||||||
variant=variant
|
|
||||||
size=size
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn DialogAction(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(default = ButtonVariant::Default)] variant: ButtonVariant,
|
|
||||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<DialogContext>();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Button
|
|
||||||
class=class
|
|
||||||
attr:data-dialog-close=ctx.target_id
|
|
||||||
attr:aria-label="Close dialog"
|
|
||||||
variant=variant
|
|
||||||
size=size
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,536 +0,0 @@
|
|||||||
use icons::{Check, ChevronRight};
|
|
||||||
use leptos::context::Provider;
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use leptos_ui::clx;
|
|
||||||
use tw_merge::*;
|
|
||||||
|
|
||||||
use crate::components::hooks::use_random::use_random_id_for;
|
|
||||||
pub use crate::components::ui::separator::Separator as DropdownMenuSeparator;
|
|
||||||
|
|
||||||
mod components {
|
|
||||||
use super::*;
|
|
||||||
clx! {DropdownMenuLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
|
|
||||||
clx! {DropdownMenuGroup, ul, "group"}
|
|
||||||
clx! {DropdownMenuItem, li, "inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4"}
|
|
||||||
clx! {DropdownMenuSubContent, ul, "dropdown__menu_sub_content", "rounded-md border bg-card shadow-lg p-1 absolute z-[100] min-w-[160px] opacity-0 invisible translate-x-[-8px] transition-all duration-200 ease-out pointer-events-none"}
|
|
||||||
clx! {DropdownMenuLink, a, "w-full inline-flex gap-2 items-center"}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub use components::*;
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* RADIO GROUP */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct DropdownMenuRadioContext<T: Clone + PartialEq + Send + Sync + 'static> {
|
|
||||||
value_signal: RwSignal<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A group of radio items where only one can be selected at a time.
|
|
||||||
#[component]
|
|
||||||
pub fn DropdownMenuRadioGroup<T>(
|
|
||||||
children: Children,
|
|
||||||
/// The signal holding the current selected value
|
|
||||||
value: RwSignal<T>,
|
|
||||||
) -> impl IntoView
|
|
||||||
where
|
|
||||||
T: Clone + PartialEq + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
let ctx = DropdownMenuRadioContext { value_signal: value };
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Provider value=ctx>
|
|
||||||
<ul data-name="DropdownMenuRadioGroup" role="group" class="group">
|
|
||||||
{children()}
|
|
||||||
</ul>
|
|
||||||
</Provider>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A radio item that shows a checkmark when selected.
|
|
||||||
#[component]
|
|
||||||
pub fn DropdownMenuRadioItem<T>(
|
|
||||||
children: Children,
|
|
||||||
/// The value this item represents
|
|
||||||
value: T,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
) -> impl IntoView
|
|
||||||
where
|
|
||||||
T: Clone + PartialEq + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
let ctx = expect_context::<DropdownMenuRadioContext<T>>();
|
|
||||||
|
|
||||||
let value_for_check = value.clone();
|
|
||||||
let value_for_click = value.clone();
|
|
||||||
let is_selected = move || ctx.value_signal.get() == value_for_check;
|
|
||||||
|
|
||||||
let merged_class = tw_merge!(
|
|
||||||
"group inline-flex gap-2 items-center w-full rounded-sm pl-2 pr-2 py-1.5 text-sm cursor-pointer no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<li
|
|
||||||
data-name="DropdownMenuRadioItem"
|
|
||||||
class=merged_class
|
|
||||||
role="menuitemradio"
|
|
||||||
aria-checked=move || is_selected().to_string()
|
|
||||||
data-dropdown-close="true"
|
|
||||||
on:click=move |_| {
|
|
||||||
ctx.value_signal.set(value_for_click.clone());
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-checked:opacity-100" />
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An action item in a dropdown menu (no checkmark, just triggers an action).
|
|
||||||
#[component]
|
|
||||||
pub fn DropdownMenuAction(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(optional, into)] href: Option<String>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let _ctx = expect_context::<DropdownMenuContext>();
|
|
||||||
|
|
||||||
let class = tw_merge!(
|
|
||||||
"inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground",
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(href) = href {
|
|
||||||
// Render as <a> tag when href is provided
|
|
||||||
view! {
|
|
||||||
<a data-name="DropdownMenuAction" class=class href=href data-dropdown-close="true">
|
|
||||||
{children()}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
{r#"
|
|
||||||
(function() {
|
|
||||||
const link = document.currentScript.previousElementSibling;
|
|
||||||
if (!link) return;
|
|
||||||
|
|
||||||
link.addEventListener('click', function() {
|
|
||||||
// Close dropdown on route change after navigation
|
|
||||||
let currentPath = window.location.pathname;
|
|
||||||
const checkRouteChange = () => {
|
|
||||||
if (window.location.pathname !== currentPath) {
|
|
||||||
currentPath = window.location.pathname;
|
|
||||||
|
|
||||||
// Find and close the dropdown
|
|
||||||
const dropdown = link.closest('[data-target="target__dropdown"]');
|
|
||||||
if (dropdown) {
|
|
||||||
dropdown.setAttribute('data-state', 'closed');
|
|
||||||
dropdown.style.pointerEvents = 'none';
|
|
||||||
|
|
||||||
// Unlock scroll
|
|
||||||
if (window.ScrollLock) {
|
|
||||||
window.ScrollLock.unlock(200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearInterval(routeCheckInterval);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const routeCheckInterval = setInterval(checkRouteChange, 50);
|
|
||||||
|
|
||||||
// Clear interval after 2 seconds to prevent memory leaks
|
|
||||||
setTimeout(() => clearInterval(routeCheckInterval), 2000);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
"#}
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
} else {
|
|
||||||
// Render as <button> tag when no href
|
|
||||||
view! {
|
|
||||||
<button type="button" data-name="DropdownMenuAction" class=class data-dropdown-close="true">
|
|
||||||
{children()}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum DropdownMenuAlign {
|
|
||||||
#[default]
|
|
||||||
Start,
|
|
||||||
StartOuter,
|
|
||||||
End,
|
|
||||||
EndOuter,
|
|
||||||
Center,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct DropdownMenuContext {
|
|
||||||
target_id: String,
|
|
||||||
align: DropdownMenuAlign,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn DropdownMenu(
|
|
||||||
children: Children,
|
|
||||||
#[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let dropdown_target_id = use_random_id_for("dropdown");
|
|
||||||
|
|
||||||
let ctx = DropdownMenuContext { target_id: dropdown_target_id.clone(), align };
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Provider value=ctx>
|
|
||||||
<style>
|
|
||||||
"
|
|
||||||
/* Submenu Styles */
|
|
||||||
.dropdown__menu_sub_content {
|
|
||||||
position: absolute;
|
|
||||||
inset-inline-start: calc(100% + 8px);
|
|
||||||
inset-block-start: -4px;
|
|
||||||
z-index: 100;
|
|
||||||
min-inline-size: 160px;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transform: translateX(-8px);
|
|
||||||
transition: all 0.2s ease-out;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown__menu_sub_trigger:hover .dropdown__menu_sub_content {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: translateX(0);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
"
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div data-name="DropdownMenu">{children()}</div>
|
|
||||||
</Provider>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn DropdownMenuTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<DropdownMenuContext>();
|
|
||||||
let button_class = tw_merge!(
|
|
||||||
"px-4 py-2 h-9 inline-flex justify-center items-center text-sm font-medium whitespace-nowrap rounded-md transition-colors w-fit focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class=button_class
|
|
||||||
data-name="DropdownMenuTrigger"
|
|
||||||
data-dropdown-trigger=ctx.target_id
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum DropdownMenuPosition {
|
|
||||||
#[default]
|
|
||||||
Auto,
|
|
||||||
Top,
|
|
||||||
Bottom,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn DropdownMenuContent(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(default = DropdownMenuPosition::default())] position: DropdownMenuPosition,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<DropdownMenuContext>();
|
|
||||||
|
|
||||||
let base_classes = "z-50 p-1 rounded-md border bg-card shadow-md h-fit fixed transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100";
|
|
||||||
let width_class = match ctx.align {
|
|
||||||
DropdownMenuAlign::Center => "min-w-full",
|
|
||||||
_ => "w-[180px]",
|
|
||||||
};
|
|
||||||
|
|
||||||
let class = tw_merge!(width_class, base_classes, class);
|
|
||||||
|
|
||||||
let target_id_for_script = ctx.target_id.clone();
|
|
||||||
let align_for_script = match ctx.align {
|
|
||||||
DropdownMenuAlign::Start => "start",
|
|
||||||
DropdownMenuAlign::StartOuter => "start-outer",
|
|
||||||
DropdownMenuAlign::End => "end",
|
|
||||||
DropdownMenuAlign::EndOuter => "end-outer",
|
|
||||||
DropdownMenuAlign::Center => "center",
|
|
||||||
};
|
|
||||||
|
|
||||||
let position_for_script = match position {
|
|
||||||
DropdownMenuPosition::Auto => "auto",
|
|
||||||
DropdownMenuPosition::Top => "top",
|
|
||||||
DropdownMenuPosition::Bottom => "bottom",
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div
|
|
||||||
data-name="DropdownMenuContent"
|
|
||||||
class=class
|
|
||||||
id=ctx.target_id
|
|
||||||
data-target="target__dropdown"
|
|
||||||
data-state="closed"
|
|
||||||
data-align=align_for_script
|
|
||||||
data-position=position_for_script
|
|
||||||
style="pointer-events: none;"
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
{format!(
|
|
||||||
r#"
|
|
||||||
(function() {{
|
|
||||||
const setupDropdown = () => {{
|
|
||||||
const dropdown = document.querySelector('#{}');
|
|
||||||
const trigger = document.querySelector('[data-dropdown-trigger="{}"]');
|
|
||||||
|
|
||||||
if (!dropdown || !trigger) {{
|
|
||||||
setTimeout(setupDropdown, 50);
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
|
|
||||||
if (dropdown.hasAttribute('data-initialized')) {{
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
dropdown.setAttribute('data-initialized', 'true');
|
|
||||||
|
|
||||||
let isOpen = false;
|
|
||||||
|
|
||||||
const updatePosition = () => {{
|
|
||||||
const triggerRect = trigger.getBoundingClientRect();
|
|
||||||
const dropdownRect = dropdown.getBoundingClientRect();
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const spaceBelow = viewportHeight - triggerRect.bottom;
|
|
||||||
const spaceAbove = triggerRect.top;
|
|
||||||
|
|
||||||
const align = dropdown.getAttribute('data-align') || 'start';
|
|
||||||
const position = dropdown.getAttribute('data-position') || 'auto';
|
|
||||||
|
|
||||||
// Determine if we should position above
|
|
||||||
let shouldPositionAbove = false;
|
|
||||||
if (position === 'top') {{
|
|
||||||
shouldPositionAbove = true;
|
|
||||||
}} else if (position === 'bottom') {{
|
|
||||||
shouldPositionAbove = false;
|
|
||||||
}} else {{
|
|
||||||
// Auto: position above if there's space above AND not enough space below
|
|
||||||
shouldPositionAbove = spaceAbove >= dropdownRect.height && spaceBelow < dropdownRect.height;
|
|
||||||
}}
|
|
||||||
|
|
||||||
switch (align) {{
|
|
||||||
case 'start':
|
|
||||||
if (shouldPositionAbove) {{
|
|
||||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
|
||||||
dropdown.style.transformOrigin = 'left bottom';
|
|
||||||
}} else {{
|
|
||||||
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
|
||||||
dropdown.style.transformOrigin = 'left top';
|
|
||||||
}}
|
|
||||||
dropdown.style.left = `${{triggerRect.left}}px`;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'end':
|
|
||||||
if (shouldPositionAbove) {{
|
|
||||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
|
||||||
dropdown.style.transformOrigin = 'right bottom';
|
|
||||||
}} else {{
|
|
||||||
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
|
||||||
dropdown.style.transformOrigin = 'right top';
|
|
||||||
}}
|
|
||||||
dropdown.style.left = `${{triggerRect.right - dropdownRect.width}}px`;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'start-outer':
|
|
||||||
if (shouldPositionAbove) {{
|
|
||||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
|
||||||
dropdown.style.transformOrigin = 'right bottom';
|
|
||||||
}} else {{
|
|
||||||
dropdown.style.top = `${{triggerRect.top}}px`;
|
|
||||||
dropdown.style.transformOrigin = 'right top';
|
|
||||||
}}
|
|
||||||
dropdown.style.left = `${{triggerRect.left - dropdownRect.width - 16}}px`;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'end-outer':
|
|
||||||
if (shouldPositionAbove) {{
|
|
||||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
|
||||||
dropdown.style.transformOrigin = 'left bottom';
|
|
||||||
}} else {{
|
|
||||||
dropdown.style.top = `${{triggerRect.top}}px`;
|
|
||||||
dropdown.style.transformOrigin = 'left top';
|
|
||||||
}}
|
|
||||||
dropdown.style.left = `${{triggerRect.right + 8}}px`;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'center':
|
|
||||||
if (shouldPositionAbove) {{
|
|
||||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
|
||||||
dropdown.style.transformOrigin = 'center bottom';
|
|
||||||
}} else {{
|
|
||||||
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
|
||||||
dropdown.style.transformOrigin = 'center top';
|
|
||||||
}}
|
|
||||||
dropdown.style.left = `${{triggerRect.left}}px`;
|
|
||||||
dropdown.style.minWidth = `${{triggerRect.width}}px`;
|
|
||||||
break;
|
|
||||||
}}
|
|
||||||
}};
|
|
||||||
|
|
||||||
const openDropdown = () => {{
|
|
||||||
isOpen = true;
|
|
||||||
|
|
||||||
// Set state to open first to remove scale transform for accurate measurements
|
|
||||||
dropdown.setAttribute('data-state', 'open');
|
|
||||||
|
|
||||||
// Make dropdown invisible but rendered to measure true height
|
|
||||||
dropdown.style.visibility = 'hidden';
|
|
||||||
dropdown.style.pointerEvents = 'auto';
|
|
||||||
|
|
||||||
// Force reflow to ensure height is calculated
|
|
||||||
dropdown.offsetHeight;
|
|
||||||
|
|
||||||
// Calculate position with accurate height
|
|
||||||
updatePosition();
|
|
||||||
|
|
||||||
// Now make it visible
|
|
||||||
dropdown.style.visibility = 'visible';
|
|
||||||
|
|
||||||
// Lock all scrollable elements
|
|
||||||
window.ScrollLock.lock();
|
|
||||||
|
|
||||||
// Close on click outside
|
|
||||||
setTimeout(() => {{
|
|
||||||
document.addEventListener('click', handleClickOutside);
|
|
||||||
}}, 0);
|
|
||||||
}};
|
|
||||||
|
|
||||||
const closeDropdown = () => {{
|
|
||||||
isOpen = false;
|
|
||||||
dropdown.setAttribute('data-state', 'closed');
|
|
||||||
dropdown.style.pointerEvents = 'none';
|
|
||||||
document.removeEventListener('click', handleClickOutside);
|
|
||||||
|
|
||||||
// Unlock scroll after animation (200ms delay)
|
|
||||||
window.ScrollLock.unlock(200);
|
|
||||||
}};
|
|
||||||
|
|
||||||
const handleClickOutside = (e) => {{
|
|
||||||
if (!dropdown.contains(e.target) && !trigger.contains(e.target)) {{
|
|
||||||
closeDropdown();
|
|
||||||
}}
|
|
||||||
}};
|
|
||||||
|
|
||||||
// Toggle dropdown when trigger is clicked
|
|
||||||
trigger.addEventListener('click', (e) => {{
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// Check if any other dropdown is open
|
|
||||||
const allDropdowns = document.querySelectorAll('[data-target=\"target__dropdown\"]');
|
|
||||||
let otherDropdownOpen = false;
|
|
||||||
allDropdowns.forEach(dd => {{
|
|
||||||
if (dd !== dropdown && dd.getAttribute('data-state') === 'open') {{
|
|
||||||
otherDropdownOpen = true;
|
|
||||||
dd.setAttribute('data-state', 'closed');
|
|
||||||
dd.style.pointerEvents = 'none';
|
|
||||||
// Unlock scroll
|
|
||||||
if (window.ScrollLock) {{
|
|
||||||
window.ScrollLock.unlock(200);
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
|
|
||||||
// If another dropdown was open, just close it and don't open this one
|
|
||||||
if (otherDropdownOpen) {{
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
|
|
||||||
// Normal toggle behavior
|
|
||||||
if (isOpen) {{
|
|
||||||
closeDropdown();
|
|
||||||
}} else {{
|
|
||||||
openDropdown();
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
|
|
||||||
// Close when action is clicked
|
|
||||||
const actions = dropdown.querySelectorAll('[data-dropdown-close]');
|
|
||||||
actions.forEach(action => {{
|
|
||||||
action.addEventListener('click', () => {{
|
|
||||||
closeDropdown();
|
|
||||||
}});
|
|
||||||
}});
|
|
||||||
|
|
||||||
// Handle ESC key to close
|
|
||||||
document.addEventListener('keydown', (e) => {{
|
|
||||||
if (e.key === 'Escape' && isOpen) {{
|
|
||||||
e.preventDefault();
|
|
||||||
closeDropdown();
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
}};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {{
|
|
||||||
document.addEventListener('DOMContentLoaded', setupDropdown);
|
|
||||||
}} else {{
|
|
||||||
setupDropdown();
|
|
||||||
}}
|
|
||||||
}})();
|
|
||||||
"#,
|
|
||||||
target_id_for_script,
|
|
||||||
target_id_for_script,
|
|
||||||
)}
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn DropdownMenuSub(children: Children) -> impl IntoView {
|
|
||||||
// TODO. Find a better way for dropdown__menu_sub_trigger.
|
|
||||||
clx! {DropdownMenuSubRoot, li, "dropdown__menu_sub_trigger", " relative inline-flex relative gap-2 items-center py-1.5 px-2 w-full text-sm no-underline rounded-sm transition-colors duration-200 cursor-pointer text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground"}
|
|
||||||
|
|
||||||
view! { <DropdownMenuSubRoot>{children()}</DropdownMenuSubRoot> }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn DropdownMenuSubTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
let class = tw_merge!("flex items-center justify-between w-full", class);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<span attr:data-name="DropdownMenuSubTrigger" class=class>
|
|
||||||
<span class="flex gap-2 items-center">{children()}</span>
|
|
||||||
<ChevronRight class="opacity-70 size-4" />
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn DropdownMenuSubItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
let class = tw_merge!(
|
|
||||||
"inline-flex gap-2 items-center w-full rounded-sm px-3 py-2 text-sm transition-all duration-150 ease text-popover-foreground hover:bg-accent hover:text-accent-foreground cursor-pointer hover:translate-x-[2px]",
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<li data-name="DropdownMenuSubItem" class=class data-dropdown-close="true">
|
|
||||||
{children()}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use leptos_ui::{clx, variants};
|
|
||||||
|
|
||||||
mod components {
|
|
||||||
use super::*;
|
|
||||||
clx! {Empty, div, "flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8 text-center"}
|
|
||||||
clx! {EmptyHeader, div, "flex flex-col items-center gap-2"}
|
|
||||||
clx! {EmptyTitle, h3, "text-lg font-semibold leading-none"}
|
|
||||||
clx! {EmptyDescription, p, "text-muted-foreground text-sm"}
|
|
||||||
clx! {EmptyContent, div, "flex items-center justify-center gap-2"}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub use components::*;
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
variants! {
|
|
||||||
EmptyMedia {
|
|
||||||
base: "flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
Default: "bg-transparent",
|
|
||||||
Icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
Default: "",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
component: {
|
|
||||||
element: div
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ use tw_merge::tw_merge;
|
|||||||
|
|
||||||
#[derive(Default, Clone, Copy, PartialEq, Eq, AsRefStr)]
|
#[derive(Default, Clone, Copy, PartialEq, Eq, AsRefStr)]
|
||||||
#[strum(serialize_all = "lowercase")]
|
#[strum(serialize_all = "lowercase")]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum InputType {
|
pub enum InputType {
|
||||||
#[default]
|
#[default]
|
||||||
Text,
|
Text,
|
||||||
|
|||||||
@@ -1,24 +1,4 @@
|
|||||||
pub mod accordion;
|
|
||||||
pub mod alert_dialog;
|
|
||||||
pub mod badge;
|
|
||||||
pub mod button;
|
pub mod button;
|
||||||
pub mod button_action;
|
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod checkbox;
|
|
||||||
pub mod context_menu;
|
|
||||||
pub mod data_table;
|
|
||||||
pub mod dialog;
|
|
||||||
pub mod dropdown_menu;
|
|
||||||
pub mod empty;
|
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod multi_select;
|
pub mod toast;
|
||||||
pub mod select;
|
|
||||||
pub mod separator;
|
|
||||||
pub mod sheet;
|
|
||||||
pub mod sidenav;
|
|
||||||
pub mod skeleton;
|
|
||||||
pub mod svg_icon;
|
|
||||||
pub mod switch;
|
|
||||||
pub mod table;
|
|
||||||
pub mod theme_toggle;
|
|
||||||
pub mod toast;
|
|
||||||
|
|||||||
@@ -1,294 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use icons::{Check, ChevronDown, ChevronUp};
|
|
||||||
use leptos::context::Provider;
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use tw_merge::*;
|
|
||||||
|
|
||||||
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
|
|
||||||
use crate::components::hooks::use_random::use_random_id_for;
|
|
||||||
// * Reuse @select.rs
|
|
||||||
pub use crate::components::ui::select::{
|
|
||||||
SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem, SelectLabel as MultiSelectLabel,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum MultiSelectAlign {
|
|
||||||
Start,
|
|
||||||
#[default]
|
|
||||||
Center,
|
|
||||||
End,
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn MultiSelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
|
|
||||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<span data-name="MultiSelectValue" class="text-sm text-muted-foreground truncate">
|
|
||||||
{move || {
|
|
||||||
let values = multi_select_ctx.values_signal.get();
|
|
||||||
if values.is_empty() {
|
|
||||||
placeholder.clone()
|
|
||||||
} else {
|
|
||||||
let count = values.len();
|
|
||||||
if count == 1 { "1 selected".to_string() } else { format!("{} selected", count) }
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn MultiSelectOption(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(optional, into)] value: Option<String>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
|
||||||
|
|
||||||
let value_clone = value.clone();
|
|
||||||
let is_selected = Signal::derive(move || {
|
|
||||||
if let Some(ref val) = value_clone {
|
|
||||||
multi_select_ctx.values_signal.with(|values| values.contains(val))
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let class = tw_merge!(
|
|
||||||
"group inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-name="MultiSelectOption"
|
|
||||||
class=class
|
|
||||||
role="option"
|
|
||||||
aria-selected=move || is_selected.get().to_string()
|
|
||||||
on:click=move |ev: web_sys::MouseEvent| {
|
|
||||||
ev.prevent_default();
|
|
||||||
ev.stop_propagation();
|
|
||||||
if let Some(val) = value.clone() {
|
|
||||||
multi_select_ctx
|
|
||||||
.values_signal
|
|
||||||
.update(|values| {
|
|
||||||
if values.contains(&val) {
|
|
||||||
values.remove(&val);
|
|
||||||
} else {
|
|
||||||
values.insert(val);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-selected:opacity-100" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct MultiSelectContext {
|
|
||||||
target_id: String,
|
|
||||||
values_signal: RwSignal<HashSet<String>>,
|
|
||||||
align: MultiSelectAlign,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn MultiSelect(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] values: Option<RwSignal<HashSet<String>>>,
|
|
||||||
#[prop(default = MultiSelectAlign::default())] align: MultiSelectAlign,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let multi_select_target_id = use_random_id_for("multi_select");
|
|
||||||
let values_signal = values.unwrap_or_else(|| RwSignal::new(HashSet::<String>::new()));
|
|
||||||
|
|
||||||
let multi_select_ctx = MultiSelectContext { target_id: multi_select_target_id.clone(), values_signal, align };
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Provider value=multi_select_ctx>
|
|
||||||
<div data-name="MultiSelect" class="relative w-fit">
|
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
</Provider>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn MultiSelectTrigger(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(optional, into)] id: String,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
|
||||||
|
|
||||||
let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() };
|
|
||||||
|
|
||||||
let button_class = tw_merge!(
|
|
||||||
"w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
|
|
||||||
&peer_class,
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", multi_select_ctx.target_id) };
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-name="MultiSelectTrigger"
|
|
||||||
class=button_class
|
|
||||||
id=button_id
|
|
||||||
tabindex="0"
|
|
||||||
data-multi-select-trigger=multi_select_ctx.target_id
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
<ChevronDown class="text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn MultiSelectContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
|
||||||
|
|
||||||
let align_str = match multi_select_ctx.align {
|
|
||||||
MultiSelectAlign::Start => "start",
|
|
||||||
MultiSelectAlign::Center => "center",
|
|
||||||
MultiSelectAlign::End => "end",
|
|
||||||
};
|
|
||||||
|
|
||||||
let class = tw_merge!(
|
|
||||||
"w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[align=start]:left-0 data-[align=center]:left-1/2 data-[align=center]:-translate-x-1/2 data-[align=end]:right-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
let target_id_for_script = multi_select_ctx.target_id.clone();
|
|
||||||
let target_id_for_script_2 = multi_select_ctx.target_id.clone();
|
|
||||||
|
|
||||||
// Scroll indicator signals
|
|
||||||
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div
|
|
||||||
data-name="MultiSelectContent"
|
|
||||||
class=class
|
|
||||||
id=multi_select_ctx.target_id
|
|
||||||
data-target="target__multi_select"
|
|
||||||
data-state="closed"
|
|
||||||
data-align=align_str
|
|
||||||
style="pointer-events: none;"
|
|
||||||
on:scroll=move |ev| on_scroll.run(ev)
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-scroll-up="true"
|
|
||||||
class=move || {
|
|
||||||
let is_up: bool = can_scroll_up_signal.get();
|
|
||||||
if is_up {
|
|
||||||
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
|
|
||||||
} else {
|
|
||||||
"hidden"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChevronUp class="size-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
{children()}
|
|
||||||
<div
|
|
||||||
data-scroll-down="true"
|
|
||||||
class=move || {
|
|
||||||
let is_down: bool = can_scroll_down_signal.get();
|
|
||||||
if is_down {
|
|
||||||
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
|
|
||||||
} else {
|
|
||||||
"hidden"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChevronDown class="size-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
{format!(
|
|
||||||
r#"
|
|
||||||
(function() {{
|
|
||||||
const setupMultiSelect = () => {{
|
|
||||||
const multiSelect = document.querySelector('#{}');
|
|
||||||
const trigger = document.querySelector('[data-multi-select-trigger="{}"]');
|
|
||||||
|
|
||||||
if (!multiSelect || !trigger) {{
|
|
||||||
setTimeout(setupMultiSelect, 50);
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
|
|
||||||
if (multiSelect.hasAttribute('data-initialized')) {{
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
multiSelect.setAttribute('data-initialized', 'true');
|
|
||||||
|
|
||||||
let isOpen = false;
|
|
||||||
|
|
||||||
const openMultiSelect = () => {{
|
|
||||||
isOpen = true;
|
|
||||||
if (window.ScrollLock) window.ScrollLock.lock();
|
|
||||||
multiSelect.setAttribute('data-state', 'open');
|
|
||||||
multiSelect.style.pointerEvents = 'auto';
|
|
||||||
const triggerRect = trigger.getBoundingClientRect();
|
|
||||||
multiSelect.style.minWidth = `${{triggerRect.width}}px`;
|
|
||||||
multiSelect.dispatchEvent(new Event('scroll'));
|
|
||||||
setTimeout(() => {{
|
|
||||||
document.addEventListener('click', handleClickOutside);
|
|
||||||
}}, 0);
|
|
||||||
}};
|
|
||||||
|
|
||||||
const closeMultiSelect = () => {{
|
|
||||||
isOpen = false;
|
|
||||||
multiSelect.setAttribute('data-state', 'closed');
|
|
||||||
multiSelect.style.pointerEvents = 'none';
|
|
||||||
document.removeEventListener('click', handleClickOutside);
|
|
||||||
if (window.ScrollLock) window.ScrollLock.unlock(200);
|
|
||||||
}};
|
|
||||||
|
|
||||||
const handleClickOutside = (e) => {{
|
|
||||||
if (!multiSelect.contains(e.target) && !trigger.contains(e.target)) {{
|
|
||||||
closeMultiSelect();
|
|
||||||
}}
|
|
||||||
}};
|
|
||||||
|
|
||||||
trigger.addEventListener('click', (e) => {{
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isOpen) closeMultiSelect(); else openMultiSelect();
|
|
||||||
}});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {{
|
|
||||||
if (e.key === 'Escape' && isOpen) {{
|
|
||||||
e.preventDefault();
|
|
||||||
closeMultiSelect();
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
}};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {{
|
|
||||||
document.addEventListener('DOMContentLoaded', setupMultiSelect);
|
|
||||||
}} else {{
|
|
||||||
setupMultiSelect();
|
|
||||||
}}
|
|
||||||
}})();
|
|
||||||
"#,
|
|
||||||
target_id_for_script,
|
|
||||||
target_id_for_script_2,
|
|
||||||
)}
|
|
||||||
</script>
|
|
||||||
}.into_any()
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
use icons::{Check, ChevronDown, ChevronUp};
|
|
||||||
use leptos::context::Provider;
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use leptos_ui::clx;
|
|
||||||
use strum::{AsRefStr, Display};
|
|
||||||
use tw_merge::*;
|
|
||||||
|
|
||||||
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
|
|
||||||
use crate::components::hooks::use_random::use_random_id_for;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, AsRefStr)]
|
|
||||||
pub enum SelectPosition {
|
|
||||||
#[default]
|
|
||||||
Below,
|
|
||||||
Above,
|
|
||||||
}
|
|
||||||
|
|
||||||
mod components {
|
|
||||||
use super::*;
|
|
||||||
clx! {SelectLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
|
|
||||||
clx! {SelectItem, li, "inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4"}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub use components::*;
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SelectGroup(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(default = "Select options".into(), into)] aria_label: String,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let merged_class = tw_merge!("group", class);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<ul data-name="SelectGroup" role="listbox" aria-label=aria_label class=merged_class>
|
|
||||||
{children()}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
|
|
||||||
let select_ctx = expect_context::<SelectContext>();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<span data-name="SelectValue" class="text-sm text-muted-foreground truncate">
|
|
||||||
{move || { select_ctx.value_signal.get().unwrap_or_else(|| placeholder.clone()) }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SelectOption(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(default = false.into(), into)] aria_selected: Signal<bool>,
|
|
||||||
#[prop(optional, into)] value: Option<String>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<SelectContext>();
|
|
||||||
|
|
||||||
let merged_class = tw_merge!(
|
|
||||||
"group inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm cursor-pointer no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
let value_for_check = value.clone();
|
|
||||||
let is_selected = move || aria_selected.get() || ctx.value_signal.get() == value_for_check;
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<li
|
|
||||||
data-name="SelectOption"
|
|
||||||
class=merged_class
|
|
||||||
role="option"
|
|
||||||
tabindex="0"
|
|
||||||
aria-selected=move || is_selected().to_string()
|
|
||||||
data-select-option="true"
|
|
||||||
on:click=move |_| {
|
|
||||||
let val = value.clone();
|
|
||||||
ctx.value_signal.set(val.clone());
|
|
||||||
if let Some(on_change) = ctx.on_change {
|
|
||||||
on_change.run(val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-selected:opacity-100" />
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct SelectContext {
|
|
||||||
target_id: String,
|
|
||||||
value_signal: RwSignal<Option<String>>,
|
|
||||||
on_change: Option<Callback<Option<String>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Select(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(optional, into)] default_value: Option<String>,
|
|
||||||
#[prop(optional)] on_change: Option<Callback<Option<String>>>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let select_target_id = use_random_id_for("select");
|
|
||||||
let value_signal = RwSignal::new(default_value);
|
|
||||||
|
|
||||||
let ctx = SelectContext { target_id: select_target_id.clone(), value_signal, on_change };
|
|
||||||
|
|
||||||
let merged_class = tw_merge!("relative w-fit", class);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Provider value=ctx>
|
|
||||||
<div data-name="Select" class=merged_class>
|
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
</Provider>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SelectTrigger(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(optional, into)] id: String,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<SelectContext>();
|
|
||||||
|
|
||||||
let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() };
|
|
||||||
|
|
||||||
let button_class = tw_merge!(
|
|
||||||
"w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
|
|
||||||
&peer_class,
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", ctx.target_id) };
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-name="SelectTrigger"
|
|
||||||
class=button_class
|
|
||||||
id=button_id
|
|
||||||
tabindex="0"
|
|
||||||
data-select-trigger=ctx.target_id
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
<ChevronDown class="text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SelectContent(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(default = SelectPosition::default())] position: SelectPosition,
|
|
||||||
#[prop(optional)] on_close: Option<Callback<()>>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<SelectContext>();
|
|
||||||
|
|
||||||
let merged_class = tw_merge!(
|
|
||||||
"w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] left-0 data-[position=Above]:top-auto data-[position=Above]:bottom-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[state=closed]:data-[position=Below]:origin-top data-[state=open]:data-[position=Below]:origin-top data-[state=closed]:data-[position=Above]:origin-bottom data-[state=open]:data-[position=Above]:origin-bottom [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
let target_id_for_script = ctx.target_id.clone();
|
|
||||||
let target_id_for_script_2 = ctx.target_id.clone();
|
|
||||||
|
|
||||||
// Scroll indicator signals
|
|
||||||
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div
|
|
||||||
data-name="SelectContent"
|
|
||||||
class=merged_class
|
|
||||||
on:selectclose=move |_: web_sys::CustomEvent| {
|
|
||||||
if let Some(cb) = on_close {
|
|
||||||
cb.run(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
id=ctx.target_id
|
|
||||||
data-target="target__select"
|
|
||||||
data-state="closed"
|
|
||||||
data-position=position.to_string()
|
|
||||||
style="pointer-events: none;"
|
|
||||||
on:scroll=move |ev| on_scroll.run(ev)
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-scroll-up="true"
|
|
||||||
class=move || {
|
|
||||||
let is_up: bool = can_scroll_up_signal.get();
|
|
||||||
if is_up {
|
|
||||||
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
|
|
||||||
} else {
|
|
||||||
"hidden"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChevronUp class="size-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
{children()}
|
|
||||||
<div
|
|
||||||
data-scroll-down="true"
|
|
||||||
class=move || {
|
|
||||||
let is_down: bool = can_scroll_down_signal.get();
|
|
||||||
if is_down {
|
|
||||||
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
|
|
||||||
} else {
|
|
||||||
"hidden"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChevronDown class="size-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
{format!(
|
|
||||||
r#"
|
|
||||||
(function() {{
|
|
||||||
const setupSelect = () => {{
|
|
||||||
const select = document.querySelector('#{}');
|
|
||||||
const trigger = document.querySelector('[data-select-trigger="{}"]');
|
|
||||||
|
|
||||||
if (!select || !trigger) {{
|
|
||||||
setTimeout(setupSelect, 50);
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
|
|
||||||
if (select.hasAttribute('data-initialized')) {{
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
select.setAttribute('data-initialized', 'true');
|
|
||||||
|
|
||||||
let isOpen = false;
|
|
||||||
|
|
||||||
const updatePosition = () => {{
|
|
||||||
const triggerRect = trigger.getBoundingClientRect();
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
const spaceBelow = viewportHeight - triggerRect.bottom;
|
|
||||||
const spaceAbove = triggerRect.top;
|
|
||||||
|
|
||||||
if (spaceBelow < 200 && spaceAbove > spaceBelow) {{
|
|
||||||
select.setAttribute('data-position', 'Above');
|
|
||||||
}} else {{
|
|
||||||
select.setAttribute('data-position', 'Below');
|
|
||||||
}}
|
|
||||||
|
|
||||||
select.style.minWidth = `${{triggerRect.width}}px`;
|
|
||||||
}};
|
|
||||||
|
|
||||||
const openSelect = () => {{
|
|
||||||
isOpen = true;
|
|
||||||
if (window.ScrollLock) window.ScrollLock.lock();
|
|
||||||
updatePosition();
|
|
||||||
select.setAttribute('data-state', 'open');
|
|
||||||
select.style.pointerEvents = 'auto';
|
|
||||||
select.dispatchEvent(new Event('scroll'));
|
|
||||||
setTimeout(() => {{
|
|
||||||
document.addEventListener('click', handleClickOutside);
|
|
||||||
}}, 0);
|
|
||||||
}};
|
|
||||||
|
|
||||||
const closeSelect = () => {{
|
|
||||||
isOpen = false;
|
|
||||||
select.setAttribute('data-state', 'closed');
|
|
||||||
select.style.pointerEvents = 'none';
|
|
||||||
document.removeEventListener('click', handleClickOutside);
|
|
||||||
select.dispatchEvent(new CustomEvent('selectclose', {{ bubbles: false }}));
|
|
||||||
if (window.ScrollLock) window.ScrollLock.unlock(200);
|
|
||||||
}};
|
|
||||||
|
|
||||||
const handleClickOutside = (e) => {{
|
|
||||||
if (!select.contains(e.target) && !trigger.contains(e.target)) {{
|
|
||||||
closeSelect();
|
|
||||||
}}
|
|
||||||
}};
|
|
||||||
|
|
||||||
trigger.addEventListener('click', (e) => {{
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isOpen) closeSelect(); else openSelect();
|
|
||||||
}});
|
|
||||||
|
|
||||||
const options = select.querySelectorAll('[data-select-option]');
|
|
||||||
options.forEach(option => {{
|
|
||||||
option.addEventListener('click', () => closeSelect());
|
|
||||||
}});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {{
|
|
||||||
if (e.key === 'Escape' && isOpen) {{
|
|
||||||
e.preventDefault();
|
|
||||||
closeSelect();
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
}};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {{
|
|
||||||
document.addEventListener('DOMContentLoaded', setupSelect);
|
|
||||||
}} else {{
|
|
||||||
setupSelect();
|
|
||||||
}}
|
|
||||||
}})();
|
|
||||||
"#,
|
|
||||||
target_id_for_script,
|
|
||||||
target_id_for_script_2,
|
|
||||||
)}
|
|
||||||
</script>
|
|
||||||
}.into_any()
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use tw_merge::*;
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Separator(
|
|
||||||
#[prop(into, optional)] orientation: Signal<SeparatorOrientation>,
|
|
||||||
#[prop(into, optional)] class: String,
|
|
||||||
// children: Children,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let merged_class = Memo::new(move |_| {
|
|
||||||
let orientation = orientation.get();
|
|
||||||
let separator = SeparatorClass { orientation };
|
|
||||||
separator.with_class(class.clone())
|
|
||||||
});
|
|
||||||
|
|
||||||
view! { <div class=merged_class role="separator" /> }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* 🧬 STRUCT 🧬 */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(TwClass, Default)]
|
|
||||||
#[tw(class = "shrink-0 bg-border")]
|
|
||||||
pub struct SeparatorClass {
|
|
||||||
orientation: SeparatorOrientation,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(TwVariant)]
|
|
||||||
pub enum SeparatorOrientation {
|
|
||||||
#[tw(default, class = "w-full h-[1px]")]
|
|
||||||
Default,
|
|
||||||
#[tw(class = "h-full w-[1px]")]
|
|
||||||
Vertical,
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
use icons::X;
|
|
||||||
use leptos::context::Provider;
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use leptos_ui::clx;
|
|
||||||
use tw_merge::*;
|
|
||||||
|
|
||||||
use super::button::ButtonSize;
|
|
||||||
use crate::components::hooks::use_random::use_random_id_for;
|
|
||||||
use crate::components::ui::button::{Button, ButtonVariant};
|
|
||||||
|
|
||||||
mod components {
|
|
||||||
use super::*;
|
|
||||||
clx! {SheetTitle, h2, "font-bold text-2xl"}
|
|
||||||
clx! {SheetDescription, p, "text-muted-foreground"}
|
|
||||||
clx! {SheetBody, div, "flex flex-col gap-4"}
|
|
||||||
clx! {SheetFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub use components::*;
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ CONTEXT ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SheetContext {
|
|
||||||
pub target_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
pub type SheetVariant = ButtonVariant;
|
|
||||||
pub type SheetSize = ButtonSize;
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Sheet(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
|
||||||
let sheet_target_id = use_random_id_for("sheet");
|
|
||||||
let ctx = SheetContext { target_id: sheet_target_id };
|
|
||||||
|
|
||||||
let merged_class = tw_merge!("", class);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Provider value=ctx>
|
|
||||||
<div data-name="Sheet" class=merged_class>
|
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
</Provider>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SheetTrigger(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
|
|
||||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<SheetContext>();
|
|
||||||
let trigger_id = format!("trigger_{}", ctx.target_id);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Button class=class attr:id=trigger_id attr:data-sheet-trigger=ctx.target_id variant=variant size=size>
|
|
||||||
{children()}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SheetClose(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
|
|
||||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<SheetContext>();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Button class=class attr:data-sheet-close=ctx.target_id attr:aria-label="Close sheet" variant=variant size=size>
|
|
||||||
{children()}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SheetContent(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(default = SheetDirection::Right)] direction: SheetDirection,
|
|
||||||
#[prop(into, optional)] hide_close_button: Option<bool>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<SheetContext>();
|
|
||||||
|
|
||||||
let backdrop_id = format!("{}_backdrop", ctx.target_id);
|
|
||||||
let target_id_for_script = ctx.target_id.clone();
|
|
||||||
let backdrop_id_for_script = backdrop_id.clone();
|
|
||||||
|
|
||||||
let merged_class = tw_merge!(
|
|
||||||
"fixed z-100 bg-card shadow-lg p-6 transition-transform duration-300 overflow-y-auto overscroll-y-contain",
|
|
||||||
direction.initial_position(),
|
|
||||||
direction.closed_class(),
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div
|
|
||||||
data-name="SheetBackdrop"
|
|
||||||
id=backdrop_id
|
|
||||||
class="fixed inset-0 transition-opacity duration-200 pointer-events-none z-60 bg-black/50 data-[state=closed]:opacity-0 data-[state=open]:opacity-100"
|
|
||||||
data-state="closed"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
data-name="SheetContent"
|
|
||||||
class=merged_class
|
|
||||||
id=ctx.target_id
|
|
||||||
data-direction=direction.to_string()
|
|
||||||
data-state="closed"
|
|
||||||
style="pointer-events: none;"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class=format!(
|
|
||||||
"absolute top-4 right-4 p-1 rounded-sm focus:ring-2 focus:ring-offset-2 focus:outline-none [&_svg:not([class*='size-'])]:size-4 focus:ring-ring{}",
|
|
||||||
if hide_close_button.unwrap_or(false) { " hidden" } else { "" },
|
|
||||||
)
|
|
||||||
data-sheet-close=ctx.target_id.clone()
|
|
||||||
aria-label="Close sheet"
|
|
||||||
>
|
|
||||||
<span class="hidden">"Close Sheet"</span>
|
|
||||||
<X />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
{format!(
|
|
||||||
r#"
|
|
||||||
(function() {{
|
|
||||||
const setupSheet = () => {{
|
|
||||||
const sheet = document.querySelector('#{}');
|
|
||||||
const backdrop = document.querySelector('#{}');
|
|
||||||
const trigger = document.querySelector('[data-sheet-trigger="{}"]');
|
|
||||||
|
|
||||||
if (!sheet || !backdrop || !trigger) {{
|
|
||||||
setTimeout(setupSheet, 50);
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
|
|
||||||
if (sheet.hasAttribute('data-initialized')) {{
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
sheet.setAttribute('data-initialized', 'true');
|
|
||||||
|
|
||||||
const openSheet = () => {{
|
|
||||||
if (window.ScrollLock) window.ScrollLock.lock();
|
|
||||||
sheet.setAttribute('data-state', 'open');
|
|
||||||
backdrop.setAttribute('data-state', 'open');
|
|
||||||
sheet.style.pointerEvents = 'auto';
|
|
||||||
backdrop.style.pointerEvents = 'auto';
|
|
||||||
const direction = sheet.getAttribute('data-direction');
|
|
||||||
sheet.classList.remove('translate-x-full', '-translate-x-full', 'translate-y-full', '-translate-y-full');
|
|
||||||
sheet.classList.add('translate-x-0', 'translate-y-0');
|
|
||||||
}};
|
|
||||||
|
|
||||||
const closeSheet = () => {{
|
|
||||||
sheet.setAttribute('data-state', 'closed');
|
|
||||||
backdrop.setAttribute('data-state', 'closed');
|
|
||||||
sheet.style.pointerEvents = 'none';
|
|
||||||
backdrop.style.pointerEvents = 'none';
|
|
||||||
const direction = sheet.getAttribute('data-direction');
|
|
||||||
sheet.classList.remove('translate-x-0', 'translate-y-0');
|
|
||||||
if (direction === 'Right') sheet.classList.add('translate-x-full');
|
|
||||||
else if (direction === 'Left') sheet.classList.add('-translate-x-full');
|
|
||||||
else if (direction === 'Top') sheet.classList.add('-translate-y-full');
|
|
||||||
else if (direction === 'Bottom') sheet.classList.add('translate-y-full');
|
|
||||||
if (window.ScrollLock) window.ScrollLock.unlock(300);
|
|
||||||
}};
|
|
||||||
|
|
||||||
trigger.addEventListener('click', openSheet);
|
|
||||||
const closeButtons = sheet.querySelectorAll('[data-sheet-close]');
|
|
||||||
closeButtons.forEach(btn => btn.addEventListener('click', closeSheet));
|
|
||||||
backdrop.addEventListener('click', closeSheet);
|
|
||||||
document.addEventListener('keydown', (e) => {{
|
|
||||||
if (e.key === 'Escape' && sheet.getAttribute('data-state') === 'open') {{
|
|
||||||
e.preventDefault();
|
|
||||||
closeSheet();
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
}};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {{
|
|
||||||
document.addEventListener('DOMContentLoaded', setupSheet);
|
|
||||||
}} else {{
|
|
||||||
setupSheet();
|
|
||||||
}}
|
|
||||||
}})();
|
|
||||||
"#,
|
|
||||||
target_id_for_script,
|
|
||||||
backdrop_id_for_script,
|
|
||||||
target_id_for_script,
|
|
||||||
)}
|
|
||||||
</script>
|
|
||||||
}.into_any()
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ ENUM ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, strum::AsRefStr, strum::Display)]
|
|
||||||
pub enum SheetDirection {
|
|
||||||
Right,
|
|
||||||
Left,
|
|
||||||
Top,
|
|
||||||
Bottom,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SheetDirection {
|
|
||||||
fn closed_class(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
SheetDirection::Right => "translate-x-full",
|
|
||||||
SheetDirection::Left => "-translate-x-full",
|
|
||||||
SheetDirection::Top => "-translate-y-full",
|
|
||||||
SheetDirection::Bottom => "translate-y-full",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn initial_position(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
SheetDirection::Right => "top-0 right-0 h-full w-[400px]",
|
|
||||||
SheetDirection::Left => "top-0 left-0 h-full w-[400px]",
|
|
||||||
SheetDirection::Top => "top-0 left-0 w-full h-[400px]",
|
|
||||||
SheetDirection::Bottom => "bottom-0 left-0 w-full h-[400px]",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use leptos_ui::{clx, variants, void};
|
|
||||||
|
|
||||||
mod components {
|
|
||||||
use super::*;
|
|
||||||
clx! {SidenavWrapper, div, "group/sidenav-wrapper has-data-[variant=Inset]:bg-sidenav flex h-full w-full"}
|
|
||||||
clx! {SidenavInset, div, "bg-background relative flex w-full flex-1 flex-col data-[variant=Inset]:rounded-lg data-[variant=Inset]:border data-[variant=Inset]:border-sidenav-border data-[variant=Inset]:shadow-sm data-[variant=Inset]:m-2"}
|
|
||||||
clx! {SidenavInner, div, "flex flex-col w-full h-full bg-sidenav data-[variant=Floating]:rounded-lg data-[variant=Floating]:border data-[variant=Floating]:border-sidenav-border data-[variant=Floating]:shadow-sm"}
|
|
||||||
clx! {SidenavHeader, div, "flex flex-col gap-2 p-2"}
|
|
||||||
clx! {SidenavMenu, ul, "flex flex-col gap-1 w-full min-w-0"}
|
|
||||||
clx! {SidenavMenuSub, ul, "border-sidenav-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5 group-data-[collapsible=Icon]:hidden"}
|
|
||||||
clx! {SidenavMenuItem, li, "relative group/menu-item"}
|
|
||||||
clx! {SidenavContent, div, "scrollbar__on_hover", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=Icon]:overflow-hidden"}
|
|
||||||
clx! {SidenavGroup, div, "flex relative flex-col p-2 w-full min-w-0"}
|
|
||||||
clx! {SidenavGroupContent, div, "w-full text-sm"}
|
|
||||||
clx! {SidenavGroupLabel, div, "text-sidenav-foreground/70 ring-sidenav-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 group-data-[collapsible=Icon]:-mt-8 group-data-[collapsible=Icon]:opacity-0"}
|
|
||||||
clx! {SidenavFooter, footer, "flex flex-col gap-2 p-2"}
|
|
||||||
clx! {DropdownMenuTriggerEllipsis, button, "text-sidenav-foreground ring-sidenav-ring hover:bg-sidenav-accent hover:text-sidenav-accent-foreground peer-hover/menu-button:text-sidenav-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden peer-data-[size=sm]/menu-button:top-1 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 group-data-[collapsible=Icon]:hidden peer-data-[active=true]/menu-button:text-sidenav-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0"}
|
|
||||||
|
|
||||||
void! {SidenavInput, input,
|
|
||||||
"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",
|
|
||||||
"w-full h-8 shadow-none bg-background"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub use components::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, strum::IntoStaticStr)]
|
|
||||||
pub enum SidenavVariant {
|
|
||||||
#[default]
|
|
||||||
Sidenav,
|
|
||||||
Floating,
|
|
||||||
Inset,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
|
|
||||||
pub enum SidenavSide {
|
|
||||||
#[default]
|
|
||||||
Left,
|
|
||||||
Right,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Clone, Copy, PartialEq, Eq, strum::Display)]
|
|
||||||
pub enum SidenavCollapsible {
|
|
||||||
#[default]
|
|
||||||
Offcanvas,
|
|
||||||
None,
|
|
||||||
Icon,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
|
|
||||||
pub enum SidenavState {
|
|
||||||
#[default]
|
|
||||||
Expanded,
|
|
||||||
Collapsed,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Sidenav(
|
|
||||||
#[prop(into, optional)] class: String,
|
|
||||||
#[prop(default = SidenavVariant::default())] variant: SidenavVariant,
|
|
||||||
#[prop(into)] data_state: Signal<SidenavState>,
|
|
||||||
#[prop(default = SidenavSide::default())] data_side: SidenavSide,
|
|
||||||
#[prop(default = SidenavCollapsible::default())] data_collapsible: SidenavCollapsible,
|
|
||||||
children: Children,
|
|
||||||
) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<aside
|
|
||||||
data-name="Sidenav"
|
|
||||||
data-state=move || data_state.get().to_string()
|
|
||||||
data-side=data_side.to_string()
|
|
||||||
data-collapsible=data_collapsible.to_string()
|
|
||||||
class="hidden md:block group peer text-sidenav-foreground h-full"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-name="SidenavGap"
|
|
||||||
class=tw_merge!(
|
|
||||||
"relative w-(--sidenav-width) bg-transparent transition-[width] duration-200 ease-linear",
|
|
||||||
"group-data-[collapsible=Offcanvas]:w-0",
|
|
||||||
"group-data-[state=Collapsed]:w-(--sidenav-width-icon)",
|
|
||||||
match variant {
|
|
||||||
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:w-(--sidenav-width-icon)",
|
|
||||||
SidenavVariant::Floating | SidenavVariant::Inset =>
|
|
||||||
"group-data-[collapsible=Icon]:w-[calc(var(--sidenav-width-icon)+(--spacing(4)))]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
data-name="SidenavContainer"
|
|
||||||
class=tw_merge!(
|
|
||||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidenav-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
|
||||||
class,
|
|
||||||
match data_side {
|
|
||||||
SidenavSide::Left => "left-0 group-data-[collapsible=Offcanvas]:left-[calc(var(--sidenav-width)*-1)]",
|
|
||||||
SidenavSide::Right => "right-0 group-data-[collapsible=Offcanvas]:right-[calc(var(--sidenav-width)*-1)]"
|
|
||||||
},
|
|
||||||
"group-data-[state=Collapsed]:w-(--sidenav-width-icon)",
|
|
||||||
match variant {
|
|
||||||
SidenavVariant::Sidenav => "group-data-[side=Left]:border-r group-data-[side=Right]:border-l",
|
|
||||||
SidenavVariant::Floating | SidenavVariant::Inset => "p-2",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
>
|
|
||||||
<SidenavInner attr:data-sidenav="Sidenav" attr:data-variant=variant.to_string()>
|
|
||||||
{children()}
|
|
||||||
</SidenavInner>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
variants! {
|
|
||||||
SidenavMenuButton {
|
|
||||||
base: "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidenav-ring transition-[width,height,padding] hover:bg-sidenav-accent hover:text-sidenav-accent-foreground focus-visible:ring-2 active:bg-sidenav-accent active:text-sidenav-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidenav=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-[current=page]:bg-sidenav-accent aria-[current=page]:font-medium aria-[current=page]:text-sidenav-accent-foreground data-[state=open]:hover:bg-sidenav-accent data-[state=open]:hover:text-sidenav-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 group-data-[state=Collapsed]:size-8! group-data-[state=Collapsed]:p-0! [&>svg]:stroke-2 aria-[current=page]:[&>svg]:stroke-[2.7]",
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
Default: "hover:bg-sidenav-accent hover:text-sidenav-accent-foreground",
|
|
||||||
Outline: "bg-background shadow-[0_0_0_1px_hsl(var(--sidenav-border))] hover:bg-sidenav-accent hover:text-sidenav-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidenav-accent))]",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
Default: "h-8 text-sm",
|
|
||||||
Sm: "h-7 text-xs",
|
|
||||||
Lg: "h-12",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
component: {
|
|
||||||
element: button,
|
|
||||||
support_href: true,
|
|
||||||
support_aria_current: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidenavTrigger(
|
|
||||||
#[prop(into)] data_state: RwSignal<SidenavState>,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<button
|
|
||||||
on:click=move |_| data_state.update(|s| *s = match s { SidenavState::Expanded => SidenavState::Collapsed, SidenavState::Collapsed => SidenavState::Expanded })
|
|
||||||
data-name="SidenavTrigger"
|
|
||||||
class=tw_merge!("inline-flex gap-2 justify-center items-center text-sm font-medium whitespace-nowrap rounded-md transition-all outline-none size-7 hover:bg-accent hover:text-accent-foreground focus-visible:ring-2", class)
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-left"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use tw_merge::tw_merge;
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Skeleton(
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let class = tw_merge!(
|
|
||||||
"animate-pulse rounded-md bg-muted",
|
|
||||||
class
|
|
||||||
);
|
|
||||||
view! { <div class=class /> }
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use tailwind_fuse::tw_merge;
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Switch(
|
|
||||||
#[prop(into)] checked: Signal<bool>,
|
|
||||||
#[prop(into, optional)] on_checked_change: Option<Callback<bool>>,
|
|
||||||
#[prop(into, optional)] class: String,
|
|
||||||
#[prop(into, optional)] disabled: Signal<bool>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let checked_val = move || checked.get();
|
|
||||||
let disabled_val = move || disabled.get();
|
|
||||||
|
|
||||||
let track_class = move || tw_merge!(
|
|
||||||
"inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
if checked_val() { "bg-primary" } else { "bg-input" },
|
|
||||||
class.clone()
|
|
||||||
);
|
|
||||||
|
|
||||||
let thumb_class = move || tw_merge!(
|
|
||||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
|
|
||||||
if checked_val() { "translate-x-4" } else { "translate-x-0" }
|
|
||||||
);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked=move || checked_val().to_string()
|
|
||||||
disabled=disabled_val
|
|
||||||
class=track_class
|
|
||||||
on:click=move |e| {
|
|
||||||
e.prevent_default();
|
|
||||||
if let Some(cb) = on_checked_change {
|
|
||||||
cb.run(!checked_val());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span class=thumb_class />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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",
|
|
||||||
"transition-all duration-100 active:scale-[0.98] cursor-pointer select-none hover:bg-muted/30 hover:text-foreground",
|
|
||||||
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> }
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ use leptos::prelude::*;
|
|||||||
use tw_merge::*;
|
use tw_merge::*;
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum ToastType {
|
pub enum ToastType {
|
||||||
#[default]
|
#[default]
|
||||||
Default,
|
Default,
|
||||||
@@ -14,7 +13,6 @@ pub enum ToastType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum SonnerPosition {
|
pub enum SonnerPosition {
|
||||||
TopLeft,
|
TopLeft,
|
||||||
TopCenter,
|
TopCenter,
|
||||||
@@ -25,6 +23,13 @@ pub enum SonnerPosition {
|
|||||||
BottomLeft,
|
BottomLeft,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
||||||
|
pub enum SonnerDirection {
|
||||||
|
TopDown,
|
||||||
|
#[default]
|
||||||
|
BottomUp,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct ToastData {
|
pub struct ToastData {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
@@ -32,7 +37,6 @@ pub struct ToastData {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub variant: ToastType,
|
pub variant: ToastType,
|
||||||
pub duration: u64, // ms
|
pub duration: u64, // ms
|
||||||
pub is_exiting: RwSignal<bool>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -42,173 +46,160 @@ pub struct ToasterStore {
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SonnerTrigger(
|
pub fn SonnerTrigger(
|
||||||
toast: ToastData,
|
#[prop(into, optional)] class: String,
|
||||||
index: usize,
|
#[prop(optional, default = ToastType::default())] variant: ToastType,
|
||||||
total: usize,
|
#[prop(into)] title: String,
|
||||||
position: SonnerPosition,
|
description: Option<String>,
|
||||||
is_expanded: Signal<bool>,
|
#[prop(into, optional)] position: String,
|
||||||
#[prop(optional)] on_dismiss: Option<Callback<()>>,
|
on_dismiss: Option<Callback<()>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let _ = is_expanded; // Silence unused warning while keeping prop name intact for builder
|
let variant_classes = match variant {
|
||||||
let variant_classes = match toast.variant {
|
ToastType::Default => "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
ToastType::Default => "bg-background text-foreground border-border",
|
ToastType::Success => "bg-green-500 text-white hover:bg-green-600",
|
||||||
ToastType::Success => "bg-background text-foreground border-border [&_.icon]:text-green-500",
|
ToastType::Error => "bg-red-500 text-white shadow-xs hover:bg-red-600",
|
||||||
ToastType::Error => "bg-background text-foreground border-border [&_.icon]:text-destructive",
|
ToastType::Warning => "bg-yellow-500 text-white hover:bg-yellow-600",
|
||||||
ToastType::Warning => "bg-background text-foreground border-border [&_.icon]:text-yellow-500",
|
ToastType::Info => "bg-blue-500 text-white shadow-xs hover:bg-blue-600",
|
||||||
ToastType::Info => "bg-background text-foreground border-border [&_.icon]:text-blue-500",
|
ToastType::Loading => "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
ToastType::Loading => "bg-background text-foreground border-border",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let bar_color = match toast.variant {
|
let merged_class = tw_merge!(
|
||||||
ToastType::Success => "bg-green-500",
|
"inline-flex flex-col items-start justify-center gap-1 min-w-[300px] rounded-md text-sm font-medium transition-all shadow-lg p-4 cursor-pointer pointer-events-auto border border-border/50",
|
||||||
ToastType::Error => "bg-destructive",
|
variant_classes,
|
||||||
ToastType::Warning => "bg-yellow-500",
|
class
|
||||||
ToastType::Info => "bg-blue-500",
|
);
|
||||||
_ => "bg-primary",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Simplified Style (No manual translateY needed with Flexbox)
|
// Only set position attribute if not empty
|
||||||
let style = move || {
|
let position_attr = if position.is_empty() { None } else { Some(position) };
|
||||||
format!(
|
|
||||||
"z-index: {}; opacity: 1; transition: all 0.3s ease;",
|
|
||||||
total - index
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let animation_class = move || {
|
// Clone title for data attribute usage, original moved into view
|
||||||
let pos = position.to_string();
|
let title_clone = title.clone();
|
||||||
let is_left = pos.contains("Left");
|
|
||||||
let is_exiting = toast.is_exiting.get();
|
|
||||||
|
|
||||||
match (is_left, is_exiting) {
|
|
||||||
(true, false) => "animate-sonner-in-left",
|
|
||||||
(true, true) => "animate-sonner-out-left",
|
|
||||||
(false, false) => "animate-sonner-in-right",
|
|
||||||
(false, true) => "animate-sonner-out-right",
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let icon = match toast.variant {
|
|
||||||
ToastType::Success => Some(view! { <span class="icon font-bold text-lg">"✓"</span> }.into_any()),
|
|
||||||
ToastType::Error => Some(view! { <span class="icon font-bold text-lg">"✕"</span> }.into_any()),
|
|
||||||
ToastType::Warning => Some(view! { <span class="icon font-bold text-lg">"⚠"</span> }.into_any()),
|
|
||||||
ToastType::Info => Some(view! { <span class="icon font-bold text-lg">"ℹ"</span> }.into_any()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<div
|
||||||
class=move || tw_merge!(
|
class=merged_class
|
||||||
"relative transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto",
|
data-name="SonnerTrigger"
|
||||||
"flex items-center gap-3 w-full max-w-[calc(100vw-2rem)] sm:max-w-[380px] p-4 rounded-lg border shadow-xl bg-card",
|
data-variant=variant.to_string()
|
||||||
variant_classes,
|
data-toast-title=title_clone
|
||||||
animation_class()
|
data-toast-position=position_attr
|
||||||
)
|
|
||||||
style=style
|
|
||||||
on:click=move |_| {
|
on:click=move |_| {
|
||||||
if let Some(cb) = on_dismiss {
|
if let Some(cb) = on_dismiss {
|
||||||
cb.run(());
|
cb.run(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{icon}
|
<div class="font-semibold">{title}</div>
|
||||||
<div class="flex flex-col gap-0.5 overflow-hidden flex-1">
|
{move || description.as_ref().map(|d| view! { <div class="text-xs opacity-90">{d.clone()}</div> })}
|
||||||
<div class="text-sm font-bold truncate leading-tight">{toast.title}</div>
|
|
||||||
{move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-80 truncate">{d.clone()}</div> })}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Progress Bar
|
|
||||||
<div
|
|
||||||
class=tw_merge!("absolute bottom-0 left-0 h-1 w-full opacity-30", bar_color)
|
|
||||||
style=format!(
|
|
||||||
"animation: sonner-progress {}ms linear forwards; transform-origin: left;",
|
|
||||||
toast.duration
|
|
||||||
)
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
}.into_any()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SonnerContainer(
|
||||||
|
children: Children,
|
||||||
|
#[prop(into, optional)] class: String,
|
||||||
|
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let merged_class = tw_merge!("toast__container fixed z-[9999] flex flex-col gap-2 p-4 outline-none pointer-events-none", class);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class=merged_class data-position=position.to_string()>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SonnerList(
|
||||||
|
children: Children,
|
||||||
|
#[prop(into, optional)] class: String,
|
||||||
|
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
|
||||||
|
#[prop(optional, default = SonnerDirection::default())] direction: SonnerDirection,
|
||||||
|
#[prop(into, default = "false".to_string())] expanded: String,
|
||||||
|
#[prop(into, optional)] style: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let merged_class = tw_merge!(
|
||||||
|
"contents",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class=merged_class
|
||||||
|
data-name="SonnerList"
|
||||||
|
data-sonner-toaster="true"
|
||||||
|
data-sonner-theme="light"
|
||||||
|
data-position=position.to_string()
|
||||||
|
data-expanded=expanded
|
||||||
|
data-direction=direction.to_string()
|
||||||
|
style=style
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread local storage for global access without Context
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static TOASTS: std::cell::RefCell<Option<RwSignal<Vec<ToastData>>>> = std::cell::RefCell::new(None);
|
static TOASTS: std::cell::RefCell<Option<RwSignal<Vec<ToastData>>>> = std::cell::RefCell::new(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn provide_toaster() {
|
pub fn provide_toaster() {
|
||||||
let toasts = RwSignal::new(Vec::<ToastData>::new());
|
let toasts = RwSignal::new(Vec::<ToastData>::new());
|
||||||
|
|
||||||
|
// Set global thread_local
|
||||||
TOASTS.with(|t| *t.borrow_mut() = Some(toasts));
|
TOASTS.with(|t| *t.borrow_mut() = Some(toasts));
|
||||||
|
|
||||||
|
// Also provide context for components
|
||||||
provide_context(ToasterStore { toasts });
|
provide_context(ToasterStore { toasts });
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
|
pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
|
||||||
let store = use_context::<ToasterStore>().expect("Toaster context not found");
|
// Global store'u al
|
||||||
|
let store = use_context::<ToasterStore>().expect("Toaster context not found. Call provide_toaster() in App root.");
|
||||||
let toasts = store.toasts;
|
let toasts = store.toasts;
|
||||||
|
|
||||||
let is_bottom = position.to_string().contains("Bottom");
|
// Auto-derive direction from position
|
||||||
|
let direction = match position {
|
||||||
|
SonnerPosition::TopLeft | SonnerPosition::TopCenter | SonnerPosition::TopRight => SonnerDirection::TopDown,
|
||||||
|
_ => SonnerDirection::BottomUp,
|
||||||
|
};
|
||||||
|
|
||||||
let container_class = match position {
|
let container_class = match position {
|
||||||
SonnerPosition::TopLeft => "left-6 top-6 items-start",
|
SonnerPosition::TopLeft => "left-0 top-0 items-start",
|
||||||
SonnerPosition::TopRight => "right-6 top-6 items-end",
|
SonnerPosition::TopRight => "right-0 top-0 items-end",
|
||||||
SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-6 items-center",
|
SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-0 items-center",
|
||||||
SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-6 items-center",
|
SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-0 items-center",
|
||||||
SonnerPosition::BottomLeft => "left-6 bottom-6 items-start",
|
SonnerPosition::BottomLeft => "left-0 bottom-0 items-start",
|
||||||
SonnerPosition::BottomRight => "right-6 bottom-6 items-end",
|
SonnerPosition::BottomRight => "right-0 bottom-0 items-end",
|
||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<style>
|
<SonnerContainer class=container_class position=position>
|
||||||
"@keyframes sonner-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
<SonnerList position=position direction=direction>
|
||||||
@keyframes sonner-in-right { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
<For
|
||||||
@keyframes sonner-out-right { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
|
each=move || toasts.get()
|
||||||
@keyframes sonner-in-left { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
key=|toast| toast.id
|
||||||
@keyframes sonner-out-left { from { transform: translateX(0); opacity: 1; } to { transform: translateX(-100%); opacity: 0; } }
|
children=move |toast| {
|
||||||
.animate-sonner-in-right { animation: sonner-in-right 0.3s ease-out forwards; }
|
let id = toast.id;
|
||||||
.animate-sonner-out-right { animation: sonner-out-right 0.3s ease-in forwards; }
|
view! {
|
||||||
.animate-sonner-in-left { animation: sonner-in-left 0.3s ease-out forwards; }
|
<SonnerTrigger
|
||||||
.animate-sonner-out-left { animation: sonner-out-left 0.3s ease-in forwards; }"
|
variant=toast.variant
|
||||||
</style>
|
title=toast.title
|
||||||
<div
|
description=toast.description
|
||||||
class=tw_merge!(
|
on_dismiss=Some(Callback::new(move |_| {
|
||||||
"fixed z-[100] flex gap-3 pointer-events-none w-full sm:w-[400px]",
|
|
||||||
"left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 px-4 sm:px-0", // Mobile centering fix
|
|
||||||
if is_bottom { "flex-col-reverse" } else { "flex-col" },
|
|
||||||
container_class,
|
|
||||||
"pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)]"
|
|
||||||
)
|
|
||||||
>
|
|
||||||
<For
|
|
||||||
each=move || {
|
|
||||||
let list = toasts.get();
|
|
||||||
list.into_iter().enumerate().collect::<Vec<_>>()
|
|
||||||
}
|
|
||||||
key=|(_, toast)| toast.id
|
|
||||||
children=move |(index, toast)| {
|
|
||||||
let id = toast.id;
|
|
||||||
let total = toasts.with(|t| t.len());
|
|
||||||
let is_exiting = toast.is_exiting;
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<SonnerTrigger
|
|
||||||
toast=toast
|
|
||||||
index=index
|
|
||||||
total=total
|
|
||||||
position=position
|
|
||||||
is_expanded=Signal::derive(move || true)
|
|
||||||
on_dismiss=Callback::new(move |_| {
|
|
||||||
is_exiting.set(true);
|
|
||||||
leptos::task::spawn_local(async move {
|
|
||||||
gloo_timers::future::TimeoutFuture::new(300).await;
|
|
||||||
toasts.update(|vec| vec.retain(|t| t.id != id));
|
toasts.update(|vec| vec.retain(|t| t.id != id));
|
||||||
});
|
}))
|
||||||
})
|
/>
|
||||||
/>
|
}
|
||||||
}
|
}
|
||||||
}
|
/>
|
||||||
/>
|
</SonnerList>
|
||||||
</div>
|
</SonnerContainer>
|
||||||
}.into_any()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global Helper Functions
|
||||||
pub fn toast(title: impl Into<String>, variant: ToastType) {
|
pub fn toast(title: impl Into<String>, variant: ToastType) {
|
||||||
let signal_opt = TOASTS.with(|t| *t.borrow());
|
let signal_opt = TOASTS.with(|t| *t.borrow());
|
||||||
|
|
||||||
@@ -220,32 +211,22 @@ pub fn toast(title: impl Into<String>, variant: ToastType) {
|
|||||||
description: None,
|
description: None,
|
||||||
variant,
|
variant,
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
is_exiting: RwSignal::new(false),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
toasts.update(|t| {
|
toasts.update(|t| t.push(new_toast.clone()));
|
||||||
t.push(new_toast.clone());
|
|
||||||
if t.len() > 5 {
|
|
||||||
t.remove(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Auto remove after duration
|
||||||
let duration = new_toast.duration;
|
let duration = new_toast.duration;
|
||||||
let is_exiting = new_toast.is_exiting;
|
|
||||||
leptos::task::spawn_local(async move {
|
leptos::task::spawn_local(async move {
|
||||||
gloo_timers::future::TimeoutFuture::new(duration as u32).await;
|
gloo_timers::future::TimeoutFuture::new(duration as u32).await;
|
||||||
is_exiting.set(true);
|
|
||||||
gloo_timers::future::TimeoutFuture::new(300).await;
|
|
||||||
toasts.update(|vec| vec.retain(|t| t.id != id));
|
toasts.update(|vec| vec.retain(|t| t.id != id));
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
gloo_console::warn!("ToasterStore not found (global static). Make sure provide_toaster() is called.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn toast_success(title: impl Into<String>) { toast(title, ToastType::Success); }
|
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); }
|
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); }
|
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); }
|
pub fn toast_info(title: impl Into<String>) { toast(title, ToastType::Info); }
|
||||||
@@ -5,9 +5,7 @@ use leptos::task::spawn_local;
|
|||||||
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
|
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use struct_patch::traits::Patch;
|
use struct_patch::traits::Patch;
|
||||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL};
|
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
|
|
||||||
use crate::components::ui::toast::{ToastType, toast};
|
use crate::components::ui::toast::{ToastType, toast};
|
||||||
|
|
||||||
@@ -26,6 +24,8 @@ pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
|
|||||||
toast(msg, variant);
|
toast(msg, variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); }
|
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); }
|
||||||
pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); }
|
pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); }
|
||||||
|
|
||||||
@@ -54,7 +54,6 @@ pub struct TorrentStore {
|
|||||||
pub global_stats: RwSignal<GlobalStats>,
|
pub global_stats: RwSignal<GlobalStats>,
|
||||||
pub user: RwSignal<Option<String>>,
|
pub user: RwSignal<Option<String>>,
|
||||||
pub selected_torrent: RwSignal<Option<String>>,
|
pub selected_torrent: RwSignal<Option<String>>,
|
||||||
pub push_enabled: RwSignal<bool>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn provide_torrent_store() {
|
pub fn provide_torrent_store() {
|
||||||
@@ -64,20 +63,12 @@ pub fn provide_torrent_store() {
|
|||||||
let global_stats = RwSignal::new(GlobalStats::default());
|
let global_stats = RwSignal::new(GlobalStats::default());
|
||||||
let user = RwSignal::new(Option::<String>::None);
|
let user = RwSignal::new(Option::<String>::None);
|
||||||
let selected_torrent = RwSignal::new(Option::<String>::None);
|
let selected_torrent = RwSignal::new(Option::<String>::None);
|
||||||
let push_enabled = RwSignal::new(false);
|
|
||||||
|
|
||||||
let show_browser_notification = crate::utils::notification::use_app_notification();
|
let show_browser_notification = crate::utils::notification::use_app_notification();
|
||||||
|
|
||||||
let store = TorrentStore { torrents, filter, search_query, global_stats, user, selected_torrent, push_enabled };
|
let store = TorrentStore { torrents, filter, search_query, global_stats, user, selected_torrent };
|
||||||
provide_context(store);
|
provide_context(store);
|
||||||
|
|
||||||
// Initial check for push status
|
|
||||||
spawn_local(async move {
|
|
||||||
if let Ok(enabled) = is_push_subscribed().await {
|
|
||||||
push_enabled.set(enabled);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let global_stats_for_sse = global_stats;
|
let global_stats_for_sse = global_stats;
|
||||||
let torrents_for_sse = torrents;
|
let torrents_for_sse = torrents;
|
||||||
let show_browser_notification = show_browser_notification.clone();
|
let show_browser_notification = show_browser_notification.clone();
|
||||||
@@ -88,12 +79,17 @@ pub fn provide_torrent_store() {
|
|||||||
let mut disconnect_notified = false;
|
let mut disconnect_notified = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|
||||||
|
log::debug!("SSE: Creating EventSource...");
|
||||||
let es_result = EventSource::new("/api/events");
|
let es_result = EventSource::new("/api/events");
|
||||||
match es_result {
|
match es_result {
|
||||||
Ok(mut es) => {
|
Ok(mut es) => {
|
||||||
|
log::debug!("SSE: EventSource created, subscribing...");
|
||||||
if let Ok(mut stream) = es.subscribe("message") {
|
if let Ok(mut stream) = es.subscribe("message") {
|
||||||
|
log::debug!("SSE: Subscribed to message channel");
|
||||||
let mut got_first_message = false;
|
let mut got_first_message = false;
|
||||||
while let Some(Ok((_, msg))) = stream.next().await {
|
while let Some(Ok((_, msg))) = stream.next().await {
|
||||||
|
log::debug!("SSE: Received message");
|
||||||
if !got_first_message {
|
if !got_first_message {
|
||||||
got_first_message = true;
|
got_first_message = true;
|
||||||
backoff_ms = 1000;
|
backoff_ms = 1000;
|
||||||
@@ -105,30 +101,47 @@ pub fn provide_torrent_store() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(data_str) = msg.data().as_string() {
|
if let Some(data_str) = msg.data().as_string() {
|
||||||
if let Ok(bytes) = BASE64.decode(&data_str) {
|
// Decode Base64
|
||||||
if let Ok(event) = rmp_serde::from_slice::<AppEvent>(&bytes) {
|
match BASE64.decode(&data_str) {
|
||||||
match event {
|
Ok(bytes) => {
|
||||||
AppEvent::FullList(list, _) => {
|
// Deserialize MessagePack
|
||||||
torrents_for_sse.update(|map| {
|
match rmp_serde::from_slice::<AppEvent>(&bytes) {
|
||||||
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
|
Ok(event) => {
|
||||||
map.retain(|hash, _| new_hashes.contains(hash));
|
match event {
|
||||||
for new_torrent in list { map.insert(new_torrent.hash.clone(), new_torrent); }
|
AppEvent::FullList(list, _) => {
|
||||||
});
|
log::info!("SSE: Received FullList with {} torrents", list.len());
|
||||||
}
|
torrents_for_sse.update(|map| {
|
||||||
AppEvent::Update(patch) => {
|
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
|
||||||
if let Some(hash) = patch.hash.clone() {
|
map.retain(|hash, _| new_hashes.contains(hash));
|
||||||
torrents_for_sse.update(|map| { if let Some(t) = map.get_mut(&hash) { t.apply(patch); } });
|
for new_torrent in list {
|
||||||
}
|
map.insert(new_torrent.hash.clone(), new_torrent);
|
||||||
}
|
}
|
||||||
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
|
});
|
||||||
AppEvent::Notification(n) => {
|
log::debug!("SSE: torrents map now has {} entries", torrents_for_sse.with(|m| m.len()));
|
||||||
show_toast(n.level.clone(), n.message.clone());
|
}
|
||||||
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
|
AppEvent::Update(patch) => {
|
||||||
show_browser_notification("VibeTorrent", &n.message);
|
let hash_opt = patch.hash.clone();
|
||||||
|
if let Some(hash) = hash_opt {
|
||||||
|
torrents_for_sse.update(|map| {
|
||||||
|
if let Some(t) = map.get_mut(&hash) {
|
||||||
|
t.apply(patch);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
|
||||||
|
AppEvent::Notification(n) => {
|
||||||
|
show_toast(n.level.clone(), n.message.clone());
|
||||||
|
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
|
||||||
|
show_browser_notification("VibeTorrent", &n.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(e) => log::error!("SSE: Failed to deserialize MessagePack: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(e) => log::error!("SSE: Failed to decode Base64: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,106 +158,13 @@ pub fn provide_torrent_store() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log::debug!("SSE: Reconnecting in {}ms...", backoff_ms);
|
||||||
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
|
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
|
||||||
backoff_ms = std::cmp::min(backoff_ms * 2, 30000);
|
backoff_ms = std::cmp::min(backoff_ms * 2, 30000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_push_subscribed() -> Result<bool, String> {
|
|
||||||
let window = web_sys::window().ok_or("no window")?;
|
|
||||||
let navigator = window.navigator();
|
|
||||||
let sw_container = navigator.service_worker();
|
|
||||||
|
|
||||||
let registration = wasm_bindgen_futures::JsFuture::from(sw_container.ready().map_err(|e| format!("{:?}", e))?)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("{:?}", e))?
|
|
||||||
.dyn_into::<web_sys::ServiceWorkerRegistration>()
|
|
||||||
.map_err(|_| "not a registration")?;
|
|
||||||
|
|
||||||
let push_manager = registration.push_manager().map_err(|e| format!("{:?}", e))?;
|
|
||||||
let subscription = wasm_bindgen_futures::JsFuture::from(push_manager.get_subscription().map_err(|e| format!("{:?}", e))?)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("{:?}", e))?;
|
|
||||||
|
|
||||||
Ok(!subscription.is_null())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn subscribe_to_push_notifications() {
|
pub async fn subscribe_to_push_notifications() {
|
||||||
let window = web_sys::window().expect("no window");
|
// ...
|
||||||
let navigator = window.navigator();
|
|
||||||
let sw_container = navigator.service_worker();
|
|
||||||
|
|
||||||
let registration = match wasm_bindgen_futures::JsFuture::from(sw_container.ready().expect("sw not ready")).await {
|
|
||||||
Ok(reg) => reg.dyn_into::<web_sys::ServiceWorkerRegistration>().expect("not a reg"),
|
|
||||||
Err(e) => { log::error!("SW Ready Error: {:?}", e); return; }
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. Get Public Key from Backend
|
|
||||||
let public_key_res: Result<String, _> = shared::server_fns::push::get_public_key().await;
|
|
||||||
let public_key = match public_key_res {
|
|
||||||
Ok(key) => key,
|
|
||||||
Err(e) => { log::error!("Failed to get public key: {:?}", e); return; }
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. Convert base64 key to Uint8Array
|
|
||||||
let decoded_key = BASE64_URL.decode(public_key.trim()).expect("invalid public key");
|
|
||||||
let key_array = js_sys::Uint8Array::from(&decoded_key[..]);
|
|
||||||
|
|
||||||
// 3. Prepare Options
|
|
||||||
let mut options = web_sys::PushSubscriptionOptionsInit::new();
|
|
||||||
options.set_user_visible_only(true);
|
|
||||||
options.set_application_server_key(&key_array.into());
|
|
||||||
|
|
||||||
// 4. Subscribe
|
|
||||||
let push_manager = registration.push_manager().expect("no push manager");
|
|
||||||
match wasm_bindgen_futures::JsFuture::from(push_manager.subscribe_with_options(&options).expect("subscribe failed")).await {
|
|
||||||
Ok(subscription) => {
|
|
||||||
let sub_js = subscription.clone();
|
|
||||||
|
|
||||||
// Use JS to extract JSON string representation
|
|
||||||
let json_str = js_sys::JSON::stringify(&sub_js).expect("stringify failed").as_string().expect("not a string");
|
|
||||||
let sub_obj: serde_json::Value = serde_json::from_str(&json_str).expect("serde from str failed");
|
|
||||||
|
|
||||||
let endpoint = sub_obj["endpoint"].as_str().expect("no endpoint").to_string();
|
|
||||||
let p256dh = sub_obj["keys"]["p256dh"].as_str().expect("no p256dh").to_string();
|
|
||||||
let auth = sub_obj["keys"]["auth"].as_str().expect("no auth").to_string();
|
|
||||||
|
|
||||||
// 5. Save to Backend
|
|
||||||
match shared::server_fns::push::subscribe_push(endpoint, p256dh, auth).await {
|
|
||||||
Ok(_) => {
|
|
||||||
log::info!("Push subscription saved successfully");
|
|
||||||
toast_success("Bildirimler aktif edildi");
|
|
||||||
}
|
|
||||||
Err(e) => log::error!("Failed to save subscription: {:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => log::error!("Subscription Error: {:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn unsubscribe_from_push_notifications() {
|
|
||||||
let window = web_sys::window().expect("no window");
|
|
||||||
let sw_container = window.navigator().service_worker();
|
|
||||||
|
|
||||||
let registration = wasm_bindgen_futures::JsFuture::from(sw_container.ready().expect("sw not ready")).await
|
|
||||||
.unwrap().dyn_into::<web_sys::ServiceWorkerRegistration>().unwrap();
|
|
||||||
|
|
||||||
let push_manager = registration.push_manager().unwrap();
|
|
||||||
if let Ok(sub_future) = push_manager.get_subscription() {
|
|
||||||
if let Ok(subscription) = wasm_bindgen_futures::JsFuture::from(sub_future).await {
|
|
||||||
if !subscription.is_null() {
|
|
||||||
let sub = subscription.dyn_into::<web_sys::PushSubscription>().unwrap();
|
|
||||||
let endpoint = sub.endpoint();
|
|
||||||
|
|
||||||
// 1. Unsubscribe in Browser
|
|
||||||
let _ = wasm_bindgen_futures::JsFuture::from(sub.unsubscribe().unwrap()).await;
|
|
||||||
|
|
||||||
// 2. Remove from Backend
|
|
||||||
let _ = shared::server_fns::push::unsubscribe_push(endpoint).await;
|
|
||||||
log::info!("Push subscription removed");
|
|
||||||
show_toast(NotificationLevel::Info, "Bildirimler kapatıldı");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
372
package-lock.json
generated
372
package-lock.json
generated
@@ -1,372 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "vibetorrent-v3",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"dependencies": {
|
|
||||||
"@tailwindcss/cli": "^4.1.18",
|
|
||||||
"tailwindcss": "^4.1.18",
|
|
||||||
"tw-animate-css": "^1.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
|
||||||
"version": "0.3.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
|
||||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
|
||||||
"@jridgewell/trace-mapping": "^0.3.24"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jridgewell/remapping": {
|
|
||||||
"version": "2.3.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
|
||||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@jridgewell/gen-mapping": "^0.3.5",
|
|
||||||
"@jridgewell/trace-mapping": "^0.3.24"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jridgewell/resolve-uri": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
|
||||||
"version": "1.5.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
|
||||||
"version": "0.3.31",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
|
||||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@jridgewell/resolve-uri": "^3.1.0",
|
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.3",
|
|
||||||
"is-glob": "^4.0.3",
|
|
||||||
"node-addon-api": "^7.0.0",
|
|
||||||
"picomatch": "^4.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@parcel/watcher-android-arm64": "2.5.6",
|
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.6",
|
|
||||||
"@parcel/watcher-darwin-x64": "2.5.6",
|
|
||||||
"@parcel/watcher-freebsd-x64": "2.5.6",
|
|
||||||
"@parcel/watcher-linux-arm-glibc": "2.5.6",
|
|
||||||
"@parcel/watcher-linux-arm-musl": "2.5.6",
|
|
||||||
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
|
|
||||||
"@parcel/watcher-linux-arm64-musl": "2.5.6",
|
|
||||||
"@parcel/watcher-linux-x64-glibc": "2.5.6",
|
|
||||||
"@parcel/watcher-linux-x64-musl": "2.5.6",
|
|
||||||
"@parcel/watcher-win32-arm64": "2.5.6",
|
|
||||||
"@parcel/watcher-win32-ia32": "2.5.6",
|
|
||||||
"@parcel/watcher-win32-x64": "2.5.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-darwin-arm64": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/cli": {
|
|
||||||
"version": "4.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.18.tgz",
|
|
||||||
"integrity": "sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@parcel/watcher": "^2.5.1",
|
|
||||||
"@tailwindcss/node": "4.1.18",
|
|
||||||
"@tailwindcss/oxide": "4.1.18",
|
|
||||||
"enhanced-resolve": "^5.18.3",
|
|
||||||
"mri": "^1.2.0",
|
|
||||||
"picocolors": "^1.1.1",
|
|
||||||
"tailwindcss": "4.1.18"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"tailwindcss": "dist/index.mjs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/node": {
|
|
||||||
"version": "4.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
|
|
||||||
"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
|
||||||
"enhanced-resolve": "^5.18.3",
|
|
||||||
"jiti": "^2.6.1",
|
|
||||||
"lightningcss": "1.30.2",
|
|
||||||
"magic-string": "^0.30.21",
|
|
||||||
"source-map-js": "^1.2.1",
|
|
||||||
"tailwindcss": "4.1.18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide": {
|
|
||||||
"version": "4.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
|
|
||||||
"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@tailwindcss/oxide-android-arm64": "4.1.18",
|
|
||||||
"@tailwindcss/oxide-darwin-arm64": "4.1.18",
|
|
||||||
"@tailwindcss/oxide-darwin-x64": "4.1.18",
|
|
||||||
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
|
|
||||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
|
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
|
|
||||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
|
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
|
|
||||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
|
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
|
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
|
||||||
"version": "4.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
|
|
||||||
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/enhanced-resolve": {
|
|
||||||
"version": "5.19.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
|
|
||||||
"integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"graceful-fs": "^4.2.4",
|
|
||||||
"tapable": "^2.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.13.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/graceful-fs": {
|
|
||||||
"version": "4.2.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/is-extglob": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-glob": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"is-extglob": "^2.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jiti": {
|
|
||||||
"version": "2.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
|
||||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"lightningcss-android-arm64": "1.30.2",
|
|
||||||
"lightningcss-darwin-arm64": "1.30.2",
|
|
||||||
"lightningcss-darwin-x64": "1.30.2",
|
|
||||||
"lightningcss-freebsd-x64": "1.30.2",
|
|
||||||
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
|
||||||
"lightningcss-linux-arm64-gnu": "1.30.2",
|
|
||||||
"lightningcss-linux-arm64-musl": "1.30.2",
|
|
||||||
"lightningcss-linux-x64-gnu": "1.30.2",
|
|
||||||
"lightningcss-linux-x64-musl": "1.30.2",
|
|
||||||
"lightningcss-win32-arm64-msvc": "1.30.2",
|
|
||||||
"lightningcss-win32-x64-msvc": "1.30.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-darwin-arm64": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/magic-string": {
|
|
||||||
"version": "0.30.21",
|
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
|
||||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mri": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-addon-api": {
|
|
||||||
"version": "7.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
|
||||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/picomatch": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/source-map-js": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tailwindcss": {
|
|
||||||
"version": "4.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
|
||||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tapable": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/webpack"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"@tailwindcss/cli": "^4.1.18",
|
|
||||||
"tailwindcss": "^4.1.18",
|
|
||||||
"tw-animate-css": "^1.4.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,14 +6,16 @@ edition = "2021"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||||
|
serde_json = "1"
|
||||||
struct-patch = "0.5"
|
struct-patch = "0.5"
|
||||||
rmp-serde = "1.3"
|
rmp-serde = "1.3"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
http = "1"
|
http = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
|
||||||
# Leptos 0.8.7
|
# Leptos 0.8
|
||||||
leptos = { version = "0.8.15", features = ["nightly", "msgpack"] }
|
leptos = { version = "0.8.15", features = ["nightly", "msgpack"] }
|
||||||
leptos_router = { version = "0.8.7", features = ["nightly"] }
|
leptos_router = { version = "0.8.11", features = ["nightly"] }
|
||||||
leptos_axum = { version = "0.8.7", optional = true }
|
leptos_axum = { version = "0.8.7", optional = true }
|
||||||
axum = { version = "0.8", features = ["macros"], optional = true }
|
axum = { version = "0.8", features = ["macros"], optional = true }
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,17 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run_migrations(&self) -> Result<()> {
|
async fn run_migrations(&self) -> Result<()> {
|
||||||
sqlx::migrate!("./migrations").run(&self.pool).await?;
|
tracing::info!("Starting database migrations...");
|
||||||
Ok(())
|
match sqlx::migrate!("./migrations").run(&self.pool).await {
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::info!("Database migrations completed successfully.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Database migration failed: {}", e);
|
||||||
|
Err(e.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- User Operations ---
|
// --- User Operations ---
|
||||||
|
|||||||
@@ -20,42 +20,64 @@ pub struct SetupStatus {
|
|||||||
pub completed: bool,
|
pub completed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(GetSetupStatus, "/api/server_fns", input = MsgPack, output = MsgPack)]
|
#[server(GetSetupStatus, "/api/server_fns/GetSetupStatus", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
|
pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
|
||||||
use crate::DbContext;
|
use crate::DbContext;
|
||||||
|
|
||||||
let db_context = use_context::<DbContext>().ok_or_else(|| ServerFnError::new("DB Context missing"))?;
|
tracing::info!("Checking setup status...");
|
||||||
|
let db_context = use_context::<DbContext>().ok_or_else(|| {
|
||||||
|
tracing::error!("DB Context missing in GetSetupStatus");
|
||||||
|
ServerFnError::new("DB Context missing")
|
||||||
|
})?;
|
||||||
|
|
||||||
let has_users = db_context.db.has_users().await
|
let has_users = db_context.db.has_users().await
|
||||||
.map_err(|e| ServerFnError::new(format!("DB error: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("DB error in GetSetupStatus: {}", e);
|
||||||
|
ServerFnError::new(format!("DB error: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!("Setup status: completed={}", has_users);
|
||||||
|
|
||||||
Ok(SetupStatus {
|
Ok(SetupStatus {
|
||||||
completed: has_users,
|
completed: has_users,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(Setup, "/api/server_fns", input = MsgPack, output = MsgPack)]
|
#[server(Setup, "/api/server_fns/Setup", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> {
|
pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> {
|
||||||
use crate::DbContext;
|
use crate::DbContext;
|
||||||
|
|
||||||
let db_context = use_context::<DbContext>().ok_or_else(|| ServerFnError::new("DB Context missing"))?;
|
tracing::info!("Attempting setup for user: {}", username);
|
||||||
|
let db_context = use_context::<DbContext>().ok_or_else(|| {
|
||||||
|
tracing::error!("DB Context missing in Setup");
|
||||||
|
ServerFnError::new("DB Context missing")
|
||||||
|
})?;
|
||||||
|
|
||||||
// Check if setup is already done
|
// Check if setup is already done
|
||||||
let has_users = db_context.db.has_users().await.unwrap_or(false);
|
let has_users = db_context.db.has_users().await.unwrap_or(false);
|
||||||
if has_users {
|
if has_users {
|
||||||
|
tracing::warn!("Setup attempt blocked: Setup already completed");
|
||||||
return Err(ServerFnError::new("Setup already completed"));
|
return Err(ServerFnError::new("Setup already completed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash password (low cost for MIPS)
|
// Hash password (low cost for MIPS)
|
||||||
let password_hash = bcrypt::hash(&password, 6)
|
let password_hash = bcrypt::hash(&password, 6)
|
||||||
.map_err(|_| ServerFnError::new("Hashing error"))?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("Hashing error: {}", e);
|
||||||
|
ServerFnError::new("Hashing error")
|
||||||
|
})?;
|
||||||
|
|
||||||
db_context.db.create_user(&username, &password_hash).await
|
db_context.db.create_user(&username, &password_hash).await
|
||||||
.map_err(|e| ServerFnError::new(format!("DB error: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to create user: {}", e);
|
||||||
|
ServerFnError::new(format!("DB error: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!("Setup completed successfully for user: {}", username);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(Login, "/api/server_fns", input = MsgPack, output = MsgPack)]
|
#[server(Login, "/api/server_fns/Login", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn login(username: String, password: String) -> Result<UserResponse, ServerFnError> {
|
pub async fn login(username: String, password: String) -> Result<UserResponse, ServerFnError> {
|
||||||
use crate::DbContext;
|
use crate::DbContext;
|
||||||
use leptos_axum::ResponseOptions;
|
use leptos_axum::ResponseOptions;
|
||||||
@@ -111,7 +133,7 @@ pub async fn login(username: String, password: String) -> Result<UserResponse, S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(Logout, "/api/server_fns", input = MsgPack, output = MsgPack)]
|
#[server(Logout, "/api/server_fns/Logout", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn logout() -> Result<(), ServerFnError> {
|
pub async fn logout() -> Result<(), ServerFnError> {
|
||||||
use leptos_axum::ResponseOptions;
|
use leptos_axum::ResponseOptions;
|
||||||
use cookie::{Cookie, SameSite};
|
use cookie::{Cookie, SameSite};
|
||||||
@@ -132,7 +154,7 @@ pub async fn logout() -> Result<(), ServerFnError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(GetUser, "/api/server_fns", input = MsgPack, output = MsgPack)]
|
#[server(GetUser, "/api/server_fns/GetUser", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn get_user() -> Result<Option<UserResponse>, ServerFnError> {
|
pub async fn get_user() -> Result<Option<UserResponse>, ServerFnError> {
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
use leptos_axum::extract;
|
use leptos_axum::extract;
|
||||||
|
|||||||
@@ -20,13 +20,3 @@ pub async fn subscribe_push(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(format!("Failed to save subscription: {}", e)))
|
.map_err(|e| ServerFnError::new(format!("Failed to save subscription: {}", e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(UnsubscribePush, "/api/server_fns")]
|
|
||||||
pub async fn unsubscribe_push(endpoint: String) -> Result<(), ServerFnError> {
|
|
||||||
let db_ctx = expect_context::<crate::DbContext>();
|
|
||||||
db_ctx
|
|
||||||
.db
|
|
||||||
.remove_push_subscription(&endpoint)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ServerFnError::new(format!("Failed to remove subscription: {}", e)))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
base_color = "neutral"
|
|
||||||
base_path_components = "backend/src/components"
|
|
||||||
BIN
vibetorrent.db
BIN
vibetorrent.db
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user