Compare commits
266 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cfea2aed5 | ||
|
|
ce212cb2d6 | ||
|
|
851d79029a | ||
|
|
ab27cf3eb4 | ||
|
|
7b4c9ff336 | ||
|
|
743596d701 | ||
|
|
598f038ea6 | ||
|
|
7f8c721115 | ||
|
|
ba7f1ffd91 | ||
|
|
daa24dd7ec | ||
|
|
45271b5060 | ||
|
|
4d02bc655d | ||
|
|
3c2ba477f5 | ||
|
|
6106d1cd22 | ||
|
|
50b83ebacf | ||
|
|
566308d889 | ||
|
|
e878d1fe33 | ||
|
|
d88084fb9a | ||
|
|
f8639f2967 | ||
|
|
7129c9a8eb | ||
|
|
91202e7cf8 | ||
|
|
3c2fec8b8c | ||
|
|
ec23285a6a | ||
|
|
f075a87668 | ||
|
|
3ce980239c | ||
|
|
d00fc41010 | ||
|
|
0636020a86 | ||
|
|
322e0ab4a3 | ||
|
|
89f0a5423d | ||
|
|
80f9e5cda2 | ||
|
|
a12265573c | ||
|
|
e45ec46793 | ||
|
|
0e075d6092 | ||
|
|
dbbc722f50 | ||
|
|
dd3b3f3504 | ||
|
|
bb9e06c9ed | ||
|
|
a834d185e3 | ||
|
|
4e81565ab6 | ||
|
|
795eef4bda | ||
|
|
3ad8424d17 | ||
|
|
83feb5a5cf | ||
|
|
0dd97f3d7e | ||
|
|
bb32c1f7f6 | ||
|
|
3bb2d68a65 | ||
|
|
fe117cdaec | ||
|
|
e062a3c8cd | ||
|
|
ae2c9c934d | ||
|
|
f7e1356eae | ||
|
|
98b1f059c7 | ||
|
|
a3735d0931 | ||
|
|
55f00729ee | ||
|
|
275f4a91b2 | ||
|
|
025a0c4a57 | ||
|
|
b29f9f3cc2 | ||
|
|
feede5c5b4 | ||
|
|
c1306a32a9 | ||
|
|
ed5fba4b46 | ||
|
|
f149603ac8 | ||
|
|
89ad42f24d | ||
|
|
bec804131b | ||
|
|
79979e3e09 | ||
|
|
75efd877c4 | ||
|
|
52c6f45a91 | ||
|
|
f9a8fbccfd | ||
|
|
4a0ebf0cb1 | ||
|
|
e5a68fb630 | ||
|
|
155dd07193 | ||
|
|
e5f76fe548 | ||
|
|
5e098817f2 | ||
|
|
4dcbd8187e | ||
|
|
6c0c0a0919 | ||
|
|
3158a11229 | ||
|
|
45f5d1b678 | ||
|
|
c8e3caa4fc | ||
|
|
98555f16ca | ||
|
|
5449651db6 | ||
|
|
1156f0a111 | ||
|
|
9b8c075d41 | ||
|
|
c5679f043d | ||
|
|
0b3d4d1fd4 | ||
|
|
b2fb411bb1 | ||
|
|
1225c550b7 | ||
|
|
48193db81b | ||
|
|
48d8a8e0ee | ||
|
|
945f4718eb | ||
|
|
6a2952c6f3 | ||
|
|
03b63dd5d0 | ||
|
|
7717dffc56 | ||
|
|
3a2cab7ca7 | ||
|
|
e0b5411eb1 | ||
|
|
f85adfa007 | ||
|
|
88c3cd57c1 | ||
|
|
d67215a6eb | ||
|
|
5cc2fdd8b4 | ||
|
|
38bce3fecf | ||
|
|
f1c75c468a | ||
|
|
bfb152f0d8 | ||
|
|
8a7d9957aa | ||
|
|
56e8cc03d1 | ||
|
|
04cb7d51cb | ||
|
|
555505b80e | ||
|
|
fa07fd88dc | ||
|
|
bbb8e8dc98 | ||
|
|
d09ecd21b7 | ||
|
|
9a00e341af | ||
|
|
c78dcda55e | ||
|
|
57abbb3335 | ||
|
|
315a2421c4 | ||
|
|
c135c96d27 | ||
|
|
315a2f9a53 | ||
|
|
9d160a7ef5 | ||
|
|
a24e4101e8 | ||
|
|
7539307e18 | ||
|
|
907ae66a7f | ||
|
|
f35b119c0d | ||
|
|
920704ee72 | ||
|
|
d8ad9e62d8 | ||
|
|
ea99ac62bc | ||
|
|
af13b5af09 | ||
|
|
c8907e7999 | ||
|
|
714e2cb7d5 | ||
|
|
f35b716f93 | ||
|
|
47db9fa0c0 | ||
|
|
47dc4da6d1 | ||
|
|
c501ed9207 | ||
|
|
4861faee18 | ||
|
|
6a4943d692 | ||
|
|
b27caa77f2 | ||
|
|
cba8c20d9b | ||
|
|
0cdd92dc95 | ||
|
|
b9798ce0e2 | ||
|
|
6a882b75b6 | ||
|
|
40c9f66e5c | ||
|
|
93e853977a | ||
|
|
e3bc956256 | ||
|
|
5b016aca58 | ||
|
|
5bd3d31dd6 | ||
|
|
87ddd3bb93 | ||
|
|
463249982c | ||
|
|
9447a66cc1 | ||
|
|
45247a020e | ||
|
|
77b77c7775 | ||
|
|
8ef3008cb8 | ||
|
|
ca1dd0caac | ||
|
|
ad336789d9 | ||
|
|
fa248d87ae | ||
|
|
d8a9e9e137 | ||
|
|
ca31b4018f | ||
|
|
7707bfff15 | ||
|
|
376615813b | ||
|
|
fddc81365b | ||
|
|
8815727620 | ||
|
|
c85c75659e | ||
|
|
4b3e713657 | ||
|
|
c2bf6e6fd5 | ||
|
|
94bc7cb91d | ||
|
|
5e1f4b18c2 | ||
|
|
3f370389aa | ||
|
|
a4fe8d065c | ||
|
|
3215b38272 | ||
|
|
8eb594e804 | ||
|
|
518af10cd7 | ||
|
|
0304c5cb7d | ||
|
|
cee609700a | ||
|
|
a9de8aeb5a | ||
|
|
79a88306c3 | ||
|
|
96ca09b9bd | ||
|
|
4d88660d91 | ||
|
|
1c2fa499b8 | ||
|
|
f121d5b220 | ||
|
|
449227d019 | ||
|
|
bc47a4ac5c | ||
|
|
a3bf33aee4 | ||
|
|
d7b1cef6d7 | ||
|
|
41bd4fcd1b | ||
|
|
4ed3f12d8b | ||
|
|
cd7d21cd48 | ||
|
|
95a0d59cc4 | ||
|
|
e1e8a89579 | ||
|
|
9a3aae3f37 | ||
|
|
e6d00e9d55 | ||
|
|
5a8f5169ea | ||
|
|
afdc34e131 | ||
|
|
d15392e148 | ||
|
|
f3121898e2 | ||
|
|
e1370db6ce | ||
|
|
1432dec828 | ||
|
|
1bb3475d61 | ||
|
|
cffc88443a | ||
|
|
129a4c7586 | ||
|
|
f2dfa7963e | ||
|
|
d3792e78e0 | ||
|
|
384165a958 | ||
|
|
7169e44f4e | ||
|
|
51fb85c2d8 | ||
|
|
46882caea0 | ||
|
|
e339ca1c49 | ||
|
|
a08fd9698f | ||
|
|
7d46dbd437 | ||
|
|
5f107299e3 | ||
|
|
c34133ded1 | ||
|
|
0d059cbbd3 | ||
|
|
fc83a1cc65 | ||
|
|
4e81af0599 | ||
|
|
74c3c5c17e | ||
|
|
3632a578e1 | ||
|
|
8a9905fc56 | ||
|
|
1e39cbb0c5 | ||
|
|
40be58f2fc | ||
|
|
3f08b5b54a | ||
|
|
bfec99ae35 | ||
|
|
d9afd3aa81 | ||
|
|
e72113d91d | ||
|
|
7c4ff619c1 | ||
|
|
9c4217f450 | ||
|
|
cc09002171 | ||
|
|
5d8cdd7760 | ||
|
|
145436eefc | ||
|
|
10c95c5ff3 | ||
|
|
329654cc4e | ||
|
|
22b592a652 | ||
|
|
817dc49db2 | ||
|
|
b2a60d3d1e | ||
|
|
520903fa3f | ||
|
|
c45f2f50e9 | ||
|
|
791eabe9bd | ||
|
|
12f93dd640 | ||
|
|
7306db8c2f | ||
|
|
ce0ecd62af | ||
|
|
f2379b67d8 | ||
|
|
755f35c94c | ||
|
|
175cac953e | ||
|
|
2c812fc4f6 | ||
|
|
08df851970 | ||
|
|
35faa6bfda | ||
|
|
328019e438 | ||
|
|
4f1c6326fd | ||
|
|
2e36c28c0d | ||
|
|
6530e20af2 | ||
|
|
32f4946530 | ||
|
|
619951fa1c | ||
|
|
6d45e6773f | ||
|
|
2c8a2d5956 | ||
|
|
6acb299fbe | ||
|
|
ab49c2ded5 | ||
|
|
e4957e930d | ||
|
|
ad2c6dc56e | ||
|
|
9f009bc18b | ||
|
|
643b83ac21 | ||
|
|
90b65240b2 | ||
|
|
69243a5590 | ||
|
|
10262142fc | ||
|
|
858a1c9b63 | ||
|
|
edfb7458f8 | ||
|
|
575cfa4b38 | ||
|
|
9b18b97c49 | ||
|
|
88723352fd | ||
|
|
4231e0b3a7 | ||
|
|
1177412c87 | ||
|
|
aed753c64f | ||
|
|
9d0eb11f16 | ||
|
|
3c13f99652 | ||
|
|
a948215538 | ||
|
|
13424fceeb | ||
|
|
e3eb5fbca9 | ||
|
|
08f2f540fe |
@@ -22,27 +22,46 @@ jobs:
|
||||
git fetch --depth=1 origin ${{ gitea.sha }}
|
||||
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
|
||||
run: |
|
||||
cd frontend
|
||||
npm install
|
||||
# Run Tailwind manually first
|
||||
npx @tailwindcss/cli -i input.css -o public/tailwind.css
|
||||
npx @tailwindcss/cli -i input.css -o public/tailwind.css --minify --content './src/**/*.rs'
|
||||
trunk build --release
|
||||
|
||||
- name: Build Backend (MIPS)
|
||||
env:
|
||||
# Ensure we are building a fully static binary
|
||||
# -C link-self-contained=no: Let Zig (the linker) handle CRT objects (crt1.o, etc.)
|
||||
RUSTFLAGS: "-C target-feature=+crt-static -C link-self-contained=no -C link-arg=-msoft-float"
|
||||
# -s ve -w ile binary içindeki gereksiz tüm yükleri siliyoruz.
|
||||
RUSTFLAGS: "-C target-feature=+crt-static -C link-self-contained=no -C link-arg=-msoft-float -C link-arg=-s -C link-arg=-w"
|
||||
CFLAGS_mips_unknown_linux_musl: "-msoft-float"
|
||||
run: |
|
||||
cd backend
|
||||
cargo zigbuild --target mips-unknown-linux-musl --release -Z build-std=std,panic_abort
|
||||
file target/mips-unknown-linux-musl/release/backend
|
||||
# Sadece gerekli özellikleri derliyoruz (Boyut tasarrufu için swagger kapalı)
|
||||
cargo zigbuild -p backend --target mips-unknown-linux-musl --release -Z build-std=std,panic_abort --no-default-features --features push-notifications
|
||||
|
||||
- name: Rename Binary
|
||||
run: mv target/mips-unknown-linux-musl/release/backend target/mips-unknown-linux-musl/release/vibetorrent-mips
|
||||
- name: Create Release Assets
|
||||
run: |
|
||||
mv target/mips-unknown-linux-musl/release/backend target/mips-unknown-linux-musl/release/vibetorrent-mips
|
||||
|
||||
- name: Generate Release Tag
|
||||
id: tag
|
||||
@@ -56,8 +75,10 @@ jobs:
|
||||
REPO="admin/vibetorrent"
|
||||
API_URL="${{ gitea.server_url }}/api/v1"
|
||||
|
||||
# Create release
|
||||
RELEASE_RESPONSE=$(curl -s -X POST "${API_URL}/repos/${REPO}/releases" -H "Authorization: token ${RELEASE_TOKEN}" -H "Content-Type: application/json" -d "{
|
||||
RELEASE_RESPONSE=$(curl -s -X POST "${API_URL}/repos/${REPO}/releases" \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\": \"${TAG}\",
|
||||
\"name\": \"Release ${TAG}\",
|
||||
\"body\": \"Automated build from commit ${{ gitea.sha }}\",
|
||||
@@ -66,15 +87,9 @@ jobs:
|
||||
}")
|
||||
|
||||
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
|
||||
echo "Release ID: $RELEASE_ID"
|
||||
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then exit 1; fi
|
||||
|
||||
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
|
||||
echo "Failed to create release:"
|
||||
echo "$RELEASE_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upload binary as release asset
|
||||
curl -s -X POST "${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=vibetorrent-mips" -H "Authorization: token ${RELEASE_TOKEN}" -H "Content-Type: application/octet-stream" --data-binary @target/mips-unknown-linux-musl/release/vibetorrent-mips
|
||||
|
||||
echo "Release ${TAG} created with binary attached."
|
||||
curl -s -X POST "${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=vibetorrent-mips" \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @target/mips-unknown-linux-musl/release/vibetorrent-mips
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,3 +6,5 @@ result.xml
|
||||
frontend/dist
|
||||
backend.log
|
||||
.runner
|
||||
.env
|
||||
backend/.env
|
||||
|
||||
1471
Cargo.lock
generated
1471
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
@@ -2,13 +2,23 @@
|
||||
members = ["backend", "frontend", "shared"]
|
||||
resolver = "2"
|
||||
|
||||
# Optimize for size (aggressive)
|
||||
[[workspace.metadata.leptos]]
|
||||
tailwind-input-file = "frontend/input.css"
|
||||
|
||||
[profile.release]
|
||||
# En küçük binary boyutu
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
# En derin kod temizliği (dead code elimination)
|
||||
lto = "fat"
|
||||
# En iyi optimizasyon için tek birim derleme
|
||||
codegen-units = 1
|
||||
# Hata izleme kodlarını atarak yer kazan
|
||||
panic = "abort"
|
||||
# Sembolleri ve hata ayıklama bilgilerini kesin sil
|
||||
strip = true
|
||||
# Artık (incremental) build'i kapat ki optimizasyon tam olsun
|
||||
incremental = false
|
||||
|
||||
[patch.crates-io]
|
||||
coarsetime = { path = "third_party/coarsetime" }
|
||||
coarsetime = { path = "patches/coarsetime" }
|
||||
|
||||
|
||||
@@ -3,3 +3,12 @@ RTORRENT_SOCKET=/tmp/rtorrent.sock
|
||||
|
||||
# Backend Listen Port
|
||||
PORT=3000
|
||||
|
||||
# Database URL
|
||||
DATABASE_URL=sqlite:vibetorrent.db
|
||||
|
||||
# VAPID Keys for Push Notifications
|
||||
# Generate new keys for production using: npx web-push generate-vapid-keys
|
||||
VAPID_PUBLIC_KEY=YOUR_PUBLIC_VAPID_KEY
|
||||
VAPID_PRIVATE_KEY=YOUR_PRIVATE_VAPID_KEY
|
||||
VAPID_EMAIL=mailto:your-email@example.com
|
||||
@@ -4,38 +4,49 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = ["push-notifications"]
|
||||
default = ["swagger"] # push-notifications kaldırıldı
|
||||
push-notifications = ["web-push", "openssl"]
|
||||
swagger = ["utoipa-swagger-ui"]
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["macros", "ws"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower = { version = "0.4", features = ["util", "timeout"] }
|
||||
tower-http = { version = "0.5", features = ["fs", "trace", "cors", "compression-full"] }
|
||||
tower = { version = "0.5", features = ["util", "timeout"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rmp-serde = "1.3"
|
||||
struct-patch = "0.5"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tokio-stream = "0.1"
|
||||
bytes = "1"
|
||||
futures = "0.3"
|
||||
quick-xml = { version = "0.31", features = ["serde", "serialize"] }
|
||||
# We might need `tokio-util` for codecs if we implement SCGI manually
|
||||
tokio-util = { version = "0.7", features = ["codec", "io"] }
|
||||
clap = { version = "4.4", features = ["derive", "env"] }
|
||||
rust-embed = "8.2"
|
||||
mime_guess = "2.0"
|
||||
shared = { path = "../shared" }
|
||||
shared = { path = "../shared", features = ["ssr"] }
|
||||
thiserror = "2.0.18"
|
||||
dotenvy = "0.15.7"
|
||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
|
||||
utoipa-swagger-ui = { version = "9.0", features = ["axum"], optional = true }
|
||||
web-push = { version = "0.10", default-features = false, features = ["hyper-client"], optional = true }
|
||||
base64 = "0.22"
|
||||
openssl = { version = "0.10", features = ["vendored"], optional = true }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||
bcrypt = "0.17.0"
|
||||
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||
rand = "0.8"
|
||||
anyhow = "1.0.101"
|
||||
time = { version = "0.3.47", features = ["serde", "formatting", "parsing"] }
|
||||
tower_governor = "0.8.0"
|
||||
governor = "0.10.4"
|
||||
|
||||
# Leptos
|
||||
leptos = { version = "0.8.15", features = ["nightly"] }
|
||||
leptos_axum = { version = "0.8.7" }
|
||||
jsonwebtoken = "9"
|
||||
tw_merge = { version = "0.1.17", features = ["variant"] }
|
||||
icons = { version = "0.18.0", features = ["leptos"] }
|
||||
leptos_ui = "0.3.20"
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite, Row};
|
||||
use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
pool: Pool<Sqlite>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub async fn new(db_url: &str) -> Result<Self> {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.acquire_timeout(Duration::from_secs(3))
|
||||
.connect(db_url)
|
||||
.await?;
|
||||
|
||||
let db = Self { pool };
|
||||
db.init().await?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
async fn init(&self) -> Result<()> {
|
||||
// Create users table
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// Create sessions table
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- User Operations ---
|
||||
|
||||
pub async fn create_user(&self, username: &str, password_hash: &str) -> Result<()> {
|
||||
sqlx::query("INSERT INTO users (username, password_hash) VALUES (?, ?)")
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(&self, username: &str) -> Result<Option<(i64, String)>> {
|
||||
let row = sqlx::query("SELECT id, password_hash FROM users WHERE username = ?")
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| (r.get(0), r.get(1))))
|
||||
}
|
||||
|
||||
pub async fn has_users(&self) -> Result<bool> {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.0 > 0)
|
||||
}
|
||||
|
||||
// --- Session Operations ---
|
||||
|
||||
pub async fn create_session(&self, user_id: i64, token: &str, expires_at: i64) -> Result<()> {
|
||||
sqlx::query("INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime(?, 'unixepoch'))")
|
||||
.bind(token)
|
||||
.bind(user_id)
|
||||
.bind(expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_session_user(&self, token: &str) -> Result<Option<i64>> {
|
||||
let row = sqlx::query("SELECT user_id FROM sessions WHERE token = ? AND expires_at > datetime('now')")
|
||||
.bind(token)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| r.get(0)))
|
||||
}
|
||||
|
||||
pub async fn delete_session(&self, token: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM sessions WHERE token = ?")
|
||||
.bind(token)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use shared::{AppEvent, NotificationLevel, SystemNotification, Torrent, TorrentUpdate};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -8,99 +9,54 @@ pub enum DiffResult {
|
||||
}
|
||||
|
||||
pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
|
||||
// 1. Structural Check (Length or Order changed)
|
||||
if old.len() != new.len() {
|
||||
return DiffResult::FullUpdate;
|
||||
}
|
||||
|
||||
for (i, t) in new.iter().enumerate() {
|
||||
if old[i].hash != t.hash {
|
||||
let old_map: HashMap<&str, &Torrent> = old.iter().map(|t| (t.hash.as_str(), t)).collect();
|
||||
|
||||
for new_t in new {
|
||||
if !old_map.contains_key(new_t.hash.as_str()) {
|
||||
return DiffResult::FullUpdate;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Field Updates
|
||||
let mut events = Vec::new();
|
||||
|
||||
for (i, new_t) in new.iter().enumerate() {
|
||||
let old_t = &old[i];
|
||||
|
||||
// Initialize with all None
|
||||
let mut update = TorrentUpdate {
|
||||
hash: new_t.hash.clone(),
|
||||
name: None,
|
||||
size: None,
|
||||
down_rate: None,
|
||||
up_rate: None,
|
||||
percent_complete: None,
|
||||
completed: None,
|
||||
eta: None,
|
||||
status: None,
|
||||
error_message: None,
|
||||
label: None,
|
||||
};
|
||||
for new_t in new {
|
||||
let old_t = old_map.get(new_t.hash.as_str()).unwrap();
|
||||
|
||||
// Manuel diff creating TorrentUpdate (which is the Patch struct)
|
||||
let mut patch = TorrentUpdate::default();
|
||||
let mut has_changes = false;
|
||||
|
||||
// Compare fields
|
||||
if old_t.name != new_t.name {
|
||||
update.name = Some(new_t.name.clone());
|
||||
has_changes = true;
|
||||
}
|
||||
if old_t.size != new_t.size {
|
||||
update.size = Some(new_t.size);
|
||||
has_changes = true;
|
||||
}
|
||||
if old_t.down_rate != new_t.down_rate {
|
||||
update.down_rate = Some(new_t.down_rate);
|
||||
has_changes = true;
|
||||
}
|
||||
if old_t.up_rate != new_t.up_rate {
|
||||
update.up_rate = Some(new_t.up_rate);
|
||||
has_changes = true;
|
||||
}
|
||||
if (old_t.percent_complete - new_t.percent_complete).abs() > 0.01 {
|
||||
update.percent_complete = Some(new_t.percent_complete);
|
||||
has_changes = true;
|
||||
|
||||
// Check for torrent completion: reached 100%
|
||||
if old_t.name != new_t.name { patch.name = Some(new_t.name.clone()); has_changes = true; }
|
||||
if old_t.size != new_t.size { patch.size = Some(new_t.size); has_changes = true; }
|
||||
if old_t.down_rate != new_t.down_rate { patch.down_rate = Some(new_t.down_rate); has_changes = true; }
|
||||
if old_t.up_rate != new_t.up_rate { patch.up_rate = Some(new_t.up_rate); has_changes = true; }
|
||||
if old_t.completed != new_t.completed { patch.completed = Some(new_t.completed); has_changes = true; }
|
||||
if old_t.eta != new_t.eta { patch.eta = Some(new_t.eta); has_changes = true; }
|
||||
if (old_t.percent_complete - new_t.percent_complete).abs() > 0.01 {
|
||||
patch.percent_complete = Some(new_t.percent_complete);
|
||||
has_changes = true;
|
||||
|
||||
if old_t.percent_complete < 100.0 && new_t.percent_complete >= 100.0 {
|
||||
tracing::info!("Torrent completed: {} ({})", new_t.name, new_t.hash);
|
||||
events.push(AppEvent::Notification(SystemNotification {
|
||||
level: NotificationLevel::Success,
|
||||
message: format!("Torrent tamamlandı: {}", new_t.name),
|
||||
}));
|
||||
}
|
||||
}
|
||||
if old_t.completed != new_t.completed {
|
||||
update.completed = Some(new_t.completed);
|
||||
has_changes = true;
|
||||
}
|
||||
if old_t.eta != new_t.eta {
|
||||
update.eta = Some(new_t.eta);
|
||||
has_changes = true;
|
||||
}
|
||||
if old_t.status != new_t.status {
|
||||
update.status = Some(new_t.status.clone());
|
||||
has_changes = true;
|
||||
|
||||
// Log status changes for debugging
|
||||
tracing::info!(
|
||||
"Torrent status changed: {} ({}) {:?} -> {:?}",
|
||||
new_t.name, new_t.hash, old_t.status, new_t.status
|
||||
);
|
||||
}
|
||||
if old_t.error_message != new_t.error_message {
|
||||
update.error_message = Some(new_t.error_message.clone());
|
||||
has_changes = true;
|
||||
}
|
||||
if old_t.label != new_t.label {
|
||||
update.label = new_t.label.clone();
|
||||
has_changes = true;
|
||||
}
|
||||
if old_t.status != new_t.status { patch.status = Some(new_t.status.clone()); has_changes = true; }
|
||||
if old_t.error_message != new_t.error_message { patch.error_message = Some(new_t.error_message.clone()); has_changes = true; }
|
||||
if old_t.label != new_t.label { patch.label = Some(new_t.label.clone()); has_changes = true; }
|
||||
|
||||
if has_changes {
|
||||
events.push(AppEvent::Update(update));
|
||||
// Set the hash (not an Option in Patch usually, but check shared/src/lib.rs)
|
||||
// Wait, TorrentUpdate is a Patch, does it have 'hash' field?
|
||||
// Yes, because Torrent has 'hash' field.
|
||||
patch.hash = Some(new_t.hash.clone());
|
||||
events.push(AppEvent::Update(patch));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,4 +66,4 @@ pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
|
||||
tracing::debug!("Generated {} partial updates", events.len());
|
||||
DiffResult::Partial(events)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +1,2 @@
|
||||
use crate::{db::Db, AppState};
|
||||
use axum::{
|
||||
extract::{State, Json},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use time::Duration;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserResponse {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/login",
|
||||
request_body = LoginRequest,
|
||||
responses(
|
||||
(status = 200, description = "Login successful"),
|
||||
(status = 401, description = "Invalid credentials"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn login_handler(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let user = match state.db.get_user_by_username(&payload.username).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("DB error during login: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let (user_id, password_hash) = user;
|
||||
|
||||
match bcrypt::verify(&payload.password, &password_hash) {
|
||||
Ok(true) => {
|
||||
// Create session
|
||||
let token: String = (0..32).map(|_| {
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
rand::thread_rng().sample(Alphanumeric) as char
|
||||
}).collect();
|
||||
|
||||
// Expires in 30 days
|
||||
let expires_in = 60 * 60 * 24 * 30;
|
||||
let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + expires_in;
|
||||
|
||||
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
|
||||
tracing::error!("Failed to create session: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create session").into_response();
|
||||
}
|
||||
|
||||
let cookie = Cookie::build(("auth_token", token))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.max_age(Duration::seconds(expires_in))
|
||||
.build();
|
||||
|
||||
(StatusCode::OK, jar.add(cookie), "Login successful").into_response()
|
||||
}
|
||||
_ => (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/logout",
|
||||
responses(
|
||||
(status = 200, description = "Logged out")
|
||||
)
|
||||
)]
|
||||
pub async fn logout_handler(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(token) = jar.get("auth_token") {
|
||||
let _ = state.db.delete_session(token.value()).await;
|
||||
}
|
||||
|
||||
let cookie = Cookie::build(("auth_token", ""))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.max_age(Duration::seconds(-1)) // Expire immediately
|
||||
.build();
|
||||
|
||||
(StatusCode::OK, jar.add(cookie), "Logged out").into_response()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/check",
|
||||
responses(
|
||||
(status = 200, description = "Authenticated"),
|
||||
(status = 401, description = "Not authenticated")
|
||||
)
|
||||
)]
|
||||
pub async fn check_auth_handler(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(token) = jar.get("auth_token") {
|
||||
match state.db.get_session_user(token.value()).await {
|
||||
Ok(Some(_)) => return StatusCode::OK.into_response(),
|
||||
_ => {} // Invalid session
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::UNAUTHORIZED.into_response()
|
||||
}
|
||||
// This file is intentionally empty as authentication is now handled by Server Functions.
|
||||
// See shared/src/server_fns/auth.rs
|
||||
|
||||
@@ -1,37 +1,18 @@
|
||||
use crate::{
|
||||
xmlrpc::{self, RpcParam},
|
||||
AppState,
|
||||
};
|
||||
#[cfg(feature = "push-notifications")]
|
||||
use crate::push;
|
||||
use axum::{
|
||||
extract::{Json, Path, State},
|
||||
http::{header, StatusCode, Uri},
|
||||
response::IntoResponse,
|
||||
BoxError,
|
||||
};
|
||||
use rust_embed::RustEmbed;
|
||||
use serde::Deserialize;
|
||||
use shared::{
|
||||
GlobalLimitRequest, SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest, TorrentFile,
|
||||
TorrentPeer, TorrentTracker,
|
||||
};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub mod auth;
|
||||
pub mod setup;
|
||||
pub mod notifications;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../frontend/dist"]
|
||||
pub struct Asset;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct AddTorrentRequest {
|
||||
/// Magnet link or Torrent file URL
|
||||
#[schema(example = "magnet:?xt=urn:btih:...")]
|
||||
uri: String,
|
||||
}
|
||||
|
||||
pub async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
let mut path = uri.path().trim_start_matches('/').to_string();
|
||||
|
||||
@@ -48,7 +29,6 @@ pub async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
if path.contains('.') {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
// Fallback to index.html for SPA routing
|
||||
match Asset::get("index.html") {
|
||||
Some(content) => {
|
||||
let mime = mime_guess::from_path("index.html").first_or_octet_stream();
|
||||
@@ -60,614 +40,6 @@ pub async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// --- TORRENT ACTIONS ---
|
||||
|
||||
/// Add a new torrent via magnet link or URL
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/torrents/add",
|
||||
request_body = AddTorrentRequest,
|
||||
responses(
|
||||
(status = 200, description = "Torrent added successfully"),
|
||||
(status = 500, description = "Internal server error or rTorrent fault")
|
||||
)
|
||||
)]
|
||||
pub async fn add_torrent_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AddTorrentRequest>,
|
||||
) -> StatusCode {
|
||||
tracing::info!(
|
||||
"Received add_torrent request. URI length: {}",
|
||||
payload.uri.len()
|
||||
);
|
||||
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||
let params = vec![RpcParam::from(""), RpcParam::from(payload.uri.as_str())];
|
||||
|
||||
match client.call("load.start", ¶ms).await {
|
||||
Ok(response) => {
|
||||
tracing::debug!("rTorrent response to load.start: {}", response);
|
||||
if response.contains("faultCode") {
|
||||
tracing::error!("rTorrent returned fault: {}", response);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
// Note: Frontend shows its own toast, no SSE notification needed
|
||||
StatusCode::OK
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to add torrent: {}", e);
|
||||
// Note: Frontend shows its own toast, no SSE notification needed
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform an action on a torrent (start, stop, delete)
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/torrents/action",
|
||||
request_body = TorrentActionRequest,
|
||||
responses(
|
||||
(status = 200, description = "Action executed successfully"),
|
||||
(status = 400, description = "Invalid action or request"),
|
||||
(status = 403, description = "Forbidden: Security risk detected"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn handle_torrent_action(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<TorrentActionRequest>,
|
||||
) -> impl IntoResponse {
|
||||
tracing::info!(
|
||||
"Received action: {} for hash: {}",
|
||||
payload.action,
|
||||
payload.hash
|
||||
);
|
||||
|
||||
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||
|
||||
// Special handling for delete_with_data
|
||||
if payload.action == "delete_with_data" {
|
||||
return match delete_torrent_with_data(&client, &payload.hash).await {
|
||||
Ok(msg) => {
|
||||
// Note: Frontend shows its own toast
|
||||
(StatusCode::OK, msg).into_response()
|
||||
}
|
||||
Err((status, msg)) => (status, msg).into_response(),
|
||||
};
|
||||
}
|
||||
|
||||
let method = match payload.action.as_str() {
|
||||
"start" => "d.start",
|
||||
"stop" => "d.stop",
|
||||
"delete" => "d.erase",
|
||||
_ => return (StatusCode::BAD_REQUEST, "Invalid action").into_response(),
|
||||
};
|
||||
|
||||
let params = vec![RpcParam::from(payload.hash.as_str())];
|
||||
|
||||
match client.call(method, ¶ms).await {
|
||||
Ok(_) => {
|
||||
// Note: Frontend shows its own toast, no SSE notification needed
|
||||
(StatusCode::OK, "Action executed").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("RPC error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to execute action",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to handle secure deletion of torrent data
|
||||
async fn delete_torrent_with_data(
|
||||
client: &xmlrpc::RtorrentClient,
|
||||
hash: &str,
|
||||
) -> Result<&'static str, (StatusCode, String)> {
|
||||
let params_hash = vec![RpcParam::from(hash)];
|
||||
|
||||
// 1. Get Base Path
|
||||
let path_xml = client
|
||||
.call("d.base_path", ¶ms_hash)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to call rTorrent: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let path = xmlrpc::parse_string_response(&path_xml).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to parse path: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 1.5 Get Default Download Directory (Sandbox Root)
|
||||
let root_xml = client.call("directory.default", &[]).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to get valid download root: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let root_path_str = xmlrpc::parse_string_response(&root_xml).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to parse root path: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Resolve Paths (Canonicalize) to prevent .. traversal and symlink attacks
|
||||
let root_path = tokio::fs::canonicalize(std::path::Path::new(&root_path_str))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Invalid download root configuration (on server): {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Check if target path exists before trying to resolve it
|
||||
let target_path_raw = std::path::Path::new(&path);
|
||||
if !tokio::fs::try_exists(target_path_raw)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
tracing::warn!(
|
||||
"Data path not found: {:?}. Removing torrent only.",
|
||||
target_path_raw
|
||||
);
|
||||
// If file doesn't exist, we just remove the torrent entry
|
||||
client.call("d.erase", ¶ms_hash).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to erase torrent: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
return Ok("Torrent removed (Data not found)");
|
||||
}
|
||||
|
||||
let target_path = tokio::fs::canonicalize(target_path_raw)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Invalid data path: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"Delete request: Target='{:?}', Root='{:?}'",
|
||||
target_path,
|
||||
root_path
|
||||
);
|
||||
|
||||
// SECURITY CHECK: Ensure path is inside root_path
|
||||
if !target_path.starts_with(&root_path) {
|
||||
tracing::error!(
|
||||
"Security Risk: Attempted to delete path outside download directory: {:?}",
|
||||
target_path
|
||||
);
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Security Error: Cannot delete files outside default download directory".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// SECURITY CHECK: Ensure we are not deleting the root itself
|
||||
if target_path == root_path {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Security Error: Cannot delete the download root directory itself".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Erase Torrent first
|
||||
client.call("d.erase", ¶ms_hash).await.map_err(|e| {
|
||||
tracing::warn!("Failed to erase torrent entry: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to erase torrent: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 3. Delete Files via Native FS
|
||||
let delete_result = if target_path.is_dir() {
|
||||
tokio::fs::remove_dir_all(&target_path).await
|
||||
} else {
|
||||
tokio::fs::remove_file(&target_path).await
|
||||
};
|
||||
|
||||
match delete_result {
|
||||
Ok(_) => Ok("Torrent and data deleted"),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to delete data at {:?}: {}", target_path, e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to delete data: {}", e),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- NEW HANDLERS ---
|
||||
|
||||
/// Get rTorrent version
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/system/version",
|
||||
responses(
|
||||
(status = 200, description = "rTorrent version", body = String),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn get_version_handler(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||
match client.call("system.client_version", &[]).await {
|
||||
Ok(xml) => {
|
||||
let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml);
|
||||
(StatusCode::OK, version).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get version: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to get version").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get files for a torrent
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/torrents/{hash}/files",
|
||||
responses(
|
||||
(status = 200, description = "Files list", body = Vec<TorrentFile>),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
params(
|
||||
("hash" = String, Path, description = "Torrent Hash")
|
||||
)
|
||||
)]
|
||||
pub async fn get_files_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(hash): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||
let params = vec![
|
||||
RpcParam::from(hash.as_str()),
|
||||
RpcParam::from(""),
|
||||
RpcParam::from("f.path="),
|
||||
RpcParam::from("f.size_bytes="),
|
||||
RpcParam::from("f.completed_chunks="),
|
||||
RpcParam::from("f.priority="),
|
||||
];
|
||||
|
||||
match client.call("f.multicall", ¶ms).await {
|
||||
Ok(xml) => match xmlrpc::parse_multicall_response(&xml) {
|
||||
Ok(rows) => {
|
||||
let files: Vec<TorrentFile> = rows
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, row)| TorrentFile {
|
||||
index: idx as u32,
|
||||
path: row.get(0).cloned().unwrap_or_default(),
|
||||
size: row.get(1).and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||
completed_chunks: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||
priority: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||
})
|
||||
.collect();
|
||||
(StatusCode::OK, Json(files)).into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Parse error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("RPC error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get peers for a torrent
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/torrents/{hash}/peers",
|
||||
responses(
|
||||
(status = 200, description = "Peers list", body = Vec<TorrentPeer>),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
params(
|
||||
("hash" = String, Path, description = "Torrent Hash")
|
||||
)
|
||||
)]
|
||||
pub async fn get_peers_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(hash): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||
let params = vec![
|
||||
RpcParam::from(hash.as_str()),
|
||||
RpcParam::from(""),
|
||||
RpcParam::from("p.address="),
|
||||
RpcParam::from("p.client_version="),
|
||||
RpcParam::from("p.down_rate="),
|
||||
RpcParam::from("p.up_rate="),
|
||||
RpcParam::from("p.completed_percent="),
|
||||
];
|
||||
|
||||
match client.call("p.multicall", ¶ms).await {
|
||||
Ok(xml) => match xmlrpc::parse_multicall_response(&xml) {
|
||||
Ok(rows) => {
|
||||
let peers: Vec<TorrentPeer> = rows
|
||||
.into_iter()
|
||||
.map(|row| TorrentPeer {
|
||||
ip: row.get(0).cloned().unwrap_or_default(),
|
||||
client: row.get(1).cloned().unwrap_or_default(),
|
||||
down_rate: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||
up_rate: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||
progress: row.get(4).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
})
|
||||
.collect();
|
||||
(StatusCode::OK, Json(peers)).into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Parse error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("RPC error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get trackers for a torrent
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/torrents/{hash}/trackers",
|
||||
responses(
|
||||
(status = 200, description = "Trackers list", body = Vec<TorrentTracker>),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
params(
|
||||
("hash" = String, Path, description = "Torrent Hash")
|
||||
)
|
||||
)]
|
||||
pub async fn get_trackers_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(hash): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||
let params = vec![
|
||||
RpcParam::from(hash.as_str()),
|
||||
RpcParam::from(""),
|
||||
RpcParam::from("t.url="),
|
||||
RpcParam::from("t.activity_date_last="),
|
||||
RpcParam::from("t.message="),
|
||||
];
|
||||
|
||||
match client.call("t.multicall", ¶ms).await {
|
||||
Ok(xml) => {
|
||||
match xmlrpc::parse_multicall_response(&xml) {
|
||||
Ok(rows) => {
|
||||
let trackers: Vec<TorrentTracker> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
TorrentTracker {
|
||||
url: row.get(0).cloned().unwrap_or_default(),
|
||||
status: "Unknown".to_string(), // Derive from type/activity?
|
||||
message: row.get(2).cloned().unwrap_or_default(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(StatusCode::OK, Json(trackers)).into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Parse error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("RPC error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set file priority
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/torrents/files/priority",
|
||||
request_body = SetFilePriorityRequest,
|
||||
responses(
|
||||
(status = 200, description = "Priority updated"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn set_file_priority_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SetFilePriorityRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||
|
||||
// f.set_priority takes "hash", index, priority
|
||||
// Priority: 0 (off), 1 (normal), 2 (high)
|
||||
// f.set_priority is tricky. Let's send as string first as before, or int if we knew.
|
||||
// Usually priorities are small integers.
|
||||
// But since we are updating everything to RpcParam, let's use Int if possible or String.
|
||||
// The previous implementation used string. Let's stick to string for now or try Int.
|
||||
// Actually, f.set_priority likely takes an integer.
|
||||
|
||||
let target = format!("{}:f{}", payload.hash, payload.file_index);
|
||||
let params = vec![
|
||||
RpcParam::from(target.as_str()),
|
||||
RpcParam::from(payload.priority as i64),
|
||||
];
|
||||
|
||||
match client.call("f.set_priority", ¶ms).await {
|
||||
Ok(_) => {
|
||||
let _ = client
|
||||
.call(
|
||||
"d.update_priorities",
|
||||
&[RpcParam::from(payload.hash.as_str())],
|
||||
)
|
||||
.await;
|
||||
(StatusCode::OK, "Priority updated").into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("RPC error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set torrent label
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/torrents/label",
|
||||
request_body = SetLabelRequest,
|
||||
responses(
|
||||
(status = 200, description = "Label updated"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn set_label_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SetLabelRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||
let params = vec![
|
||||
RpcParam::from(payload.hash.as_str()),
|
||||
RpcParam::from(payload.label),
|
||||
];
|
||||
|
||||
match client.call("d.custom1.set", ¶ms).await {
|
||||
Ok(_) => (StatusCode::OK, "Label updated").into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("RPC error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get global speed limits
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/settings/global-limits",
|
||||
responses(
|
||||
(status = 200, description = "Current limits", body = GlobalLimitRequest),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn get_global_limit_handler(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||
// throttle.global_down.max_rate, throttle.global_up.max_rate
|
||||
let down_fut = client.call("throttle.global_down.max_rate", &[]);
|
||||
let up_fut = client.call("throttle.global_up.max_rate", &[]);
|
||||
|
||||
let down = match down_fut.await {
|
||||
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
|
||||
Err(_) => -1,
|
||||
};
|
||||
|
||||
let up = match up_fut.await {
|
||||
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
|
||||
Err(_) => -1,
|
||||
};
|
||||
|
||||
let resp = GlobalLimitRequest {
|
||||
max_download_rate: Some(down),
|
||||
max_upload_rate: Some(up),
|
||||
};
|
||||
|
||||
(StatusCode::OK, Json(resp)).into_response()
|
||||
}
|
||||
|
||||
/// Set global speed limits
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/settings/global-limits",
|
||||
request_body = GlobalLimitRequest,
|
||||
responses(
|
||||
(status = 200, description = "Limits updated"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn set_global_limit_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<GlobalLimitRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||
|
||||
// Use throttle.global_*.max_rate.set_kb which is more reliable than .set (which is buggy)
|
||||
// The .set_kb method expects KB/s, so we convert bytes to KB
|
||||
|
||||
if let Some(down) = payload.max_download_rate {
|
||||
// Convert bytes/s to KB/s (divide by 1024)
|
||||
let down_kb = down / 1024;
|
||||
tracing::info!(
|
||||
"Setting download limit: {} bytes/s = {} KB/s",
|
||||
down,
|
||||
down_kb
|
||||
);
|
||||
|
||||
// Use set_kb with empty string as first param (throttle name), then value
|
||||
if let Err(e) = client
|
||||
.call(
|
||||
"throttle.global_down.max_rate.set_kb",
|
||||
&[RpcParam::from(""), RpcParam::Int(down_kb)],
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to set download limit: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to set down limit: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(up) = payload.max_upload_rate {
|
||||
// Convert bytes/s to KB/s
|
||||
let up_kb = up / 1024;
|
||||
tracing::info!("Setting upload limit: {} bytes/s = {} KB/s", up, up_kb);
|
||||
|
||||
if let Err(e) = client
|
||||
.call(
|
||||
"throttle.global_up.max_rate.set_kb",
|
||||
&[RpcParam::from(""), RpcParam::Int(up_kb)],
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to set upload limit: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to set up limit: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
(StatusCode::OK, "Limits updated").into_response()
|
||||
}
|
||||
|
||||
pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
|
||||
if err.is::<tower::timeout::error::Elapsed>() {
|
||||
(StatusCode::REQUEST_TIMEOUT, "Request timed out")
|
||||
@@ -678,42 +50,3 @@ pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- PUSH NOTIFICATION HANDLERS ---
|
||||
|
||||
#[cfg(feature = "push-notifications")]
|
||||
/// Get VAPID public key for push subscription
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/push/public-key",
|
||||
responses(
|
||||
(status = 200, description = "VAPID public key", body = String)
|
||||
)
|
||||
)]
|
||||
pub async fn get_push_public_key_handler() -> impl IntoResponse {
|
||||
let public_key = push::get_vapid_public_key();
|
||||
(StatusCode::OK, Json(serde_json::json!({ "publicKey": public_key }))).into_response()
|
||||
}
|
||||
|
||||
|
||||
#[cfg(feature = "push-notifications")]
|
||||
/// Subscribe to push notifications
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/push/subscribe",
|
||||
request_body = push::PushSubscription,
|
||||
responses(
|
||||
(status = 200, description = "Subscription saved"),
|
||||
(status = 400, description = "Invalid subscription data")
|
||||
)
|
||||
)]
|
||||
pub async fn subscribe_push_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(subscription): Json<push::PushSubscription>,
|
||||
) -> impl IntoResponse {
|
||||
tracing::info!("Received push subscription: {:?}", subscription);
|
||||
|
||||
state.push_store.add_subscription(subscription).await;
|
||||
|
||||
(StatusCode::OK, "Subscription saved").into_response()
|
||||
}
|
||||
|
||||
54
backend/src/handlers/notifications.rs
Normal file
54
backend/src/handlers/notifications.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
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
|
||||
}
|
||||
@@ -1,84 +1,2 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{State, Json},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SetupRequest {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SetupStatusResponse {
|
||||
completed: bool,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/setup/status",
|
||||
responses(
|
||||
(status = 200, description = "Setup status", body = SetupStatusResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_setup_status_handler(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let completed = match state.db.has_users().await {
|
||||
Ok(has) => has,
|
||||
Err(e) => {
|
||||
tracing::error!("DB error checking users: {}", e);
|
||||
false
|
||||
}
|
||||
};
|
||||
Json(SetupStatusResponse { completed }).into_response()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/setup",
|
||||
request_body = SetupRequest,
|
||||
responses(
|
||||
(status = 200, description = "Setup completed"),
|
||||
(status = 400, description = "Invalid request"),
|
||||
(status = 403, description = "Setup already completed"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn setup_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SetupRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// 1. Check if setup is already completed (i.e., users exist)
|
||||
match state.db.has_users().await {
|
||||
Ok(true) => return (StatusCode::FORBIDDEN, "Setup already completed").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("DB error checking users: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
|
||||
}
|
||||
Ok(false) => {} // Proceed
|
||||
}
|
||||
|
||||
// 2. Validate input
|
||||
if payload.username.len() < 3 || payload.password.len() < 6 {
|
||||
return (StatusCode::BAD_REQUEST, "Username must be at least 3 chars, password at least 6").into_response();
|
||||
}
|
||||
|
||||
// 3. Create User
|
||||
let password_hash = match bcrypt::hash(&payload.password, bcrypt::DEFAULT_COST) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to hash password: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to process password").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = state.db.create_user(&payload.username, &password_hash).await {
|
||||
tracing::error!("Failed to create user: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create user").into_response();
|
||||
}
|
||||
|
||||
(StatusCode::OK, "Setup completed successfully").into_response()
|
||||
}
|
||||
// This file is intentionally empty as setup is now handled by Server Functions.
|
||||
// See shared/src/server_fns/auth.rs
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
mod db;
|
||||
mod diff;
|
||||
mod handlers;
|
||||
#[cfg(feature = "push-notifications")]
|
||||
mod push;
|
||||
mod scgi;
|
||||
mod rate_limit;
|
||||
mod sse;
|
||||
mod xmlrpc;
|
||||
|
||||
use shared::xmlrpc;
|
||||
|
||||
use axum::error_handling::HandleErrorLayer;
|
||||
use axum::{
|
||||
@@ -30,7 +30,9 @@ use tower_http::{
|
||||
cors::CorsLayer,
|
||||
trace::TraceLayer,
|
||||
};
|
||||
#[cfg(feature = "swagger")]
|
||||
use utoipa::OpenApi;
|
||||
#[cfg(feature = "swagger")]
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -38,34 +40,50 @@ pub struct AppState {
|
||||
pub tx: Arc<watch::Sender<Vec<Torrent>>>,
|
||||
pub event_bus: broadcast::Sender<AppEvent>,
|
||||
pub scgi_socket_path: String,
|
||||
pub db: db::Db,
|
||||
pub db: shared::db::Db,
|
||||
#[cfg(feature = "push-notifications")]
|
||||
pub push_store: push::PushSubscriptionStore,
|
||||
pub notify_poll: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
async fn auth_middleware(
|
||||
state: axum::extract::State<AppState>,
|
||||
_state: axum::extract::State<AppState>,
|
||||
jar: CookieJar,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
// Skip auth for public paths
|
||||
// Skip auth for public server functions
|
||||
let path = request.uri().path();
|
||||
if path.starts_with("/api/auth/login")
|
||||
|| path.starts_with("/api/auth/check") // Used by frontend to decide where to go
|
||||
|| path.starts_with("/api/setup")
|
||||
if path.starts_with("/api/server_fns/Login")
|
||||
|| path.starts_with("/api/server_fns/login")
|
||||
|| path.starts_with("/api/server_fns/GetSetupStatus")
|
||||
|| path.starts_with("/api/server_fns/get_setup_status")
|
||||
|| path.starts_with("/api/server_fns/Setup")
|
||||
|| path.starts_with("/api/server_fns/setup")
|
||||
|| path.starts_with("/api/internal/")
|
||||
|| path.starts_with("/swagger-ui")
|
||||
|| path.starts_with("/api-docs")
|
||||
|| !path.starts_with("/api/") // Allow static files (frontend)
|
||||
|| !path.starts_with("/api/")
|
||||
{
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
|
||||
// Check token
|
||||
if let Some(token) = jar.get("auth_token") {
|
||||
match state.db.get_session_user(token.value()).await {
|
||||
Ok(Some(_)) => return Ok(next.run(request).await),
|
||||
_ => {} // Invalid
|
||||
use jsonwebtoken::{decode, Validation, DecodingKey};
|
||||
use shared::server_fns::auth::Claims;
|
||||
|
||||
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
|
||||
let validation = Validation::default();
|
||||
|
||||
match decode::<Claims>(
|
||||
token.value(),
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&validation,
|
||||
) {
|
||||
Ok(_) => return Ok(next.run(request).await),
|
||||
Err(_) => {} // Invalid token
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,33 +108,18 @@ struct Args {
|
||||
/// Database URL
|
||||
#[arg(long, env = "DATABASE_URL", default_value = "sqlite:vibetorrent.db")]
|
||||
db_url: String,
|
||||
|
||||
/// Reset password for the specified user
|
||||
#[arg(long)]
|
||||
reset_password: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "push-notifications")]
|
||||
#[cfg(feature = "swagger")]
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
handlers::add_torrent_handler,
|
||||
handlers::handle_torrent_action,
|
||||
handlers::get_version_handler,
|
||||
handlers::get_files_handler,
|
||||
handlers::get_peers_handler,
|
||||
handlers::get_trackers_handler,
|
||||
handlers::set_file_priority_handler,
|
||||
handlers::set_label_handler,
|
||||
handlers::get_global_limit_handler,
|
||||
handlers::set_global_limit_handler,
|
||||
handlers::get_push_public_key_handler,
|
||||
handlers::subscribe_push_handler,
|
||||
handlers::auth::login_handler,
|
||||
handlers::auth::logout_handler,
|
||||
handlers::auth::check_auth_handler,
|
||||
handlers::setup::setup_handler,
|
||||
handlers::setup::get_setup_status_handler
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
handlers::AddTorrentRequest,
|
||||
shared::AddTorrentRequest,
|
||||
shared::TorrentActionRequest,
|
||||
shared::Torrent,
|
||||
shared::TorrentStatus,
|
||||
@@ -126,11 +129,6 @@ struct Args {
|
||||
shared::SetFilePriorityRequest,
|
||||
shared::SetLabelRequest,
|
||||
shared::GlobalLimitRequest,
|
||||
push::PushSubscription,
|
||||
push::PushKeys,
|
||||
handlers::auth::LoginRequest,
|
||||
handlers::setup::SetupRequest,
|
||||
handlers::setup::SetupStatusResponse
|
||||
)
|
||||
),
|
||||
tags(
|
||||
@@ -139,48 +137,6 @@ struct Args {
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
#[cfg(not(feature = "push-notifications"))]
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
handlers::add_torrent_handler,
|
||||
handlers::handle_torrent_action,
|
||||
handlers::get_version_handler,
|
||||
handlers::get_files_handler,
|
||||
handlers::get_peers_handler,
|
||||
handlers::get_trackers_handler,
|
||||
handlers::set_file_priority_handler,
|
||||
handlers::set_label_handler,
|
||||
handlers::get_global_limit_handler,
|
||||
handlers::set_global_limit_handler,
|
||||
handlers::auth::login_handler,
|
||||
handlers::auth::logout_handler,
|
||||
handlers::auth::check_auth_handler,
|
||||
handlers::setup::setup_handler,
|
||||
handlers::setup::get_setup_status_handler
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
handlers::AddTorrentRequest,
|
||||
shared::TorrentActionRequest,
|
||||
shared::Torrent,
|
||||
shared::TorrentStatus,
|
||||
shared::TorrentFile,
|
||||
shared::TorrentPeer,
|
||||
shared::TorrentTracker,
|
||||
shared::SetFilePriorityRequest,
|
||||
shared::SetLabelRequest,
|
||||
shared::GlobalLimitRequest,
|
||||
handlers::auth::LoginRequest,
|
||||
handlers::setup::SetupRequest,
|
||||
handlers::setup::SetupStatusResponse
|
||||
)
|
||||
),
|
||||
tags(
|
||||
(name = "vibetorrent", description = "VibeTorrent API")
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -197,25 +153,12 @@ async fn main() {
|
||||
|
||||
// Parse CLI Args
|
||||
let args = Args::parse();
|
||||
tracing::info!("Starting VibeTorrent Backend...");
|
||||
tracing::info!("Socket: {}", args.socket);
|
||||
tracing::info!("Port: {}", args.port);
|
||||
|
||||
// Initialize Database
|
||||
tracing::info!("Connecting to database: {}", args.db_url);
|
||||
// Ensure the db file exists if it's sqlite
|
||||
if args.db_url.starts_with("sqlite:") {
|
||||
let path = args.db_url.trim_start_matches("sqlite:");
|
||||
if !std::path::Path::new(path).exists() {
|
||||
tracing::info!("Database file not found, creating: {}", path);
|
||||
match std::fs::File::create(path) {
|
||||
Ok(_) => tracing::info!("Created empty database file"),
|
||||
Err(e) => tracing::error!("Failed to create database file: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Redundant manual creation removed, shared::db handles it
|
||||
|
||||
let db: db::Db = match db::Db::new(&args.db_url).await {
|
||||
let db: shared::db::Db = match shared::db::Db::new(&args.db_url).await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to connect to database: {}", e);
|
||||
@@ -224,16 +167,90 @@ async fn main() {
|
||||
};
|
||||
tracing::info!("Database connected successfully.");
|
||||
|
||||
// Handle Password Reset
|
||||
if let Some(username) = args.reset_password {
|
||||
tracing::info!("Resetting password for user: {}", username);
|
||||
|
||||
// Check if user exists
|
||||
let user_result = db.get_user_by_username(&username).await;
|
||||
|
||||
match user_result {
|
||||
Ok(Some((user_id, _))) => {
|
||||
// Generate random password
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
let new_password: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(12)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
// Hash password (low cost for performance)
|
||||
let password_hash = match bcrypt::hash(&new_password, 6) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to hash password: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Update in DB
|
||||
if let Err(e) = db.update_password(user_id, &password_hash).await {
|
||||
tracing::error!("Failed to update password in DB: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
println!("--------------------------------------------------");
|
||||
println!("Password reset successfully for user: {}", username);
|
||||
println!("New Password: {}", new_password);
|
||||
println!("--------------------------------------------------");
|
||||
|
||||
// Invalidate existing sessions for security
|
||||
if let Err(e) = db.delete_all_sessions_for_user(user_id).await {
|
||||
tracing::warn!("Failed to invalidate existing sessions: {}", e);
|
||||
}
|
||||
|
||||
std::process::exit(0);
|
||||
},
|
||||
Ok(None) => {
|
||||
tracing::error!("User '{}' not found.", username);
|
||||
std::process::exit(1);
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!("Database error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Starting VibeTorrent Backend...");
|
||||
tracing::info!("Socket: {}", args.socket);
|
||||
tracing::info!("Port: {}", args.port);
|
||||
|
||||
// Force linking of server functions from shared crate for registration on Mac
|
||||
{
|
||||
use shared::server_fns::auth::*;
|
||||
let _ = get_setup_status;
|
||||
let _ = setup;
|
||||
let _ = login;
|
||||
let _ = logout;
|
||||
let _ = get_user;
|
||||
tracing::info!("Server functions linked successfully.");
|
||||
}
|
||||
|
||||
|
||||
// ... rest of the main function ...
|
||||
// Startup Health Check
|
||||
let socket_path = std::path::Path::new(&args.socket);
|
||||
if !socket_path.exists() {
|
||||
tracing::error!("CRITICAL: rTorrent socket not found at {:?}.", socket_path);
|
||||
tracing::warn!(
|
||||
tracing::error!(
|
||||
"HINT: Make sure rTorrent is running and the SCGI socket is enabled in .rtorrent.rc"
|
||||
);
|
||||
tracing::warn!(
|
||||
tracing::error!(
|
||||
"HINT: You can configure the socket path via --socket ARG or RTORRENT_SOCKET ENV."
|
||||
);
|
||||
tracing::error!("FATAL: VibeTorrent cannot start without a running rTorrent instance. Exiting.");
|
||||
std::process::exit(1);
|
||||
} else {
|
||||
tracing::info!("Socket file exists. Testing connection...");
|
||||
let client = xmlrpc::RtorrentClient::new(&args.socket);
|
||||
@@ -244,7 +261,11 @@ async fn main() {
|
||||
let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml);
|
||||
tracing::info!("Connected to rTorrent successfully. Version: {}", version);
|
||||
}
|
||||
Err(e) => tracing::error!("Socket exists but failed to connect to rTorrent: {}", e),
|
||||
Err(e) => {
|
||||
tracing::error!("CRITICAL: Socket exists but failed to connect to rTorrent: {}", e);
|
||||
tracing::error!("FATAL: Ensure rTorrent is fully started and the socket has correct permissions. Exiting.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,13 +276,28 @@ async fn main() {
|
||||
// Channel for Events (Diffs)
|
||||
let (event_bus, _) = broadcast::channel::<AppEvent>(1024);
|
||||
|
||||
#[cfg(feature = "push-notifications")]
|
||||
let push_store = match push::PushSubscriptionStore::with_db(&db).await {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to initialize push store: {}", e);
|
||||
push::PushSubscriptionStore::new()
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "push-notifications"))]
|
||||
let _push_store = ();
|
||||
|
||||
let notify_poll = Arc::new(tokio::sync::Notify::new());
|
||||
|
||||
let app_state = AppState {
|
||||
tx: tx.clone(),
|
||||
event_bus: event_bus.clone(),
|
||||
scgi_socket_path: args.socket.clone(),
|
||||
db: db.clone(),
|
||||
#[cfg(feature = "push-notifications")]
|
||||
push_store: push::PushSubscriptionStore::new(),
|
||||
push_store,
|
||||
notify_poll: notify_poll.clone(),
|
||||
};
|
||||
|
||||
// Spawn background task to poll rTorrent
|
||||
@@ -270,6 +306,7 @@ async fn main() {
|
||||
let socket_path = args.socket.clone(); // Clone for background task
|
||||
#[cfg(feature = "push-notifications")]
|
||||
let push_store_clone = app_state.push_store.clone();
|
||||
let notify_poll_clone = notify_poll.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let client = xmlrpc::RtorrentClient::new(&socket_path);
|
||||
@@ -278,6 +315,14 @@ async fn main() {
|
||||
let mut backoff_duration = Duration::from_secs(1);
|
||||
|
||||
loop {
|
||||
// Determine polling interval based on active clients
|
||||
let active_clients = event_bus_tx.receiver_count();
|
||||
let loop_interval = if active_clients > 0 {
|
||||
Duration::from_secs(1)
|
||||
} else {
|
||||
Duration::from_secs(60)
|
||||
};
|
||||
|
||||
// 1. Fetch Torrents
|
||||
let torrents_result = sse::fetch_torrents(&client).await;
|
||||
|
||||
@@ -313,10 +358,7 @@ async fn main() {
|
||||
|
||||
match diff::diff_torrents(&previous_torrents, &new_torrents) {
|
||||
diff::DiffResult::FullUpdate => {
|
||||
let _ = event_bus_tx.send(AppEvent::FullList {
|
||||
torrents: new_torrents.clone(),
|
||||
timestamp: now,
|
||||
});
|
||||
let _ = event_bus_tx.send(AppEvent::FullList(new_torrents.clone(), now));
|
||||
}
|
||||
diff::DiffResult::Partial(updates) => {
|
||||
for update in updates {
|
||||
@@ -348,6 +390,14 @@ async fn main() {
|
||||
}
|
||||
|
||||
previous_torrents = new_torrents;
|
||||
|
||||
// Success case: wait for the determined interval OR a wakeup notification
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(loop_interval) => {},
|
||||
_ = notify_poll_clone.notified() => {
|
||||
tracing::debug!("Background loop awakened by new client connection");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error fetching torrents in background: {}", e);
|
||||
@@ -368,66 +418,50 @@ async fn main() {
|
||||
"Backoff: Sleeping for {:?} due to rTorrent error.",
|
||||
backoff_duration
|
||||
);
|
||||
|
||||
tokio::time::sleep(backoff_duration).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Stats
|
||||
match stats_result {
|
||||
Ok(stats) => {
|
||||
let _ = event_bus_tx.send(AppEvent::Stats(stats));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Error fetching global stats: {}", e);
|
||||
}
|
||||
if let Ok(stats) = stats_result {
|
||||
let _ = event_bus_tx.send(AppEvent::Stats(stats));
|
||||
}
|
||||
|
||||
tokio::time::sleep(backoff_duration).await;
|
||||
}
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
|
||||
// Setup & Auth Routes
|
||||
.route("/api/setup/status", get(handlers::setup::get_setup_status_handler))
|
||||
.route("/api/setup", post(handlers::setup::setup_handler))
|
||||
.route("/api/auth/login", post(handlers::auth::login_handler))
|
||||
.route("/api/auth/logout", post(handlers::auth::logout_handler))
|
||||
.route("/api/auth/check", get(handlers::auth::check_auth_handler))
|
||||
// App Routes
|
||||
.route("/api/events", get(sse::sse_handler))
|
||||
.route("/api/torrents/add", post(handlers::add_torrent_handler))
|
||||
.route(
|
||||
"/api/torrents/action",
|
||||
post(handlers::handle_torrent_action),
|
||||
)
|
||||
.route("/api/system/version", get(handlers::get_version_handler))
|
||||
.route(
|
||||
"/api/torrents/{hash}/files",
|
||||
get(handlers::get_files_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/torrents/{hash}/peers",
|
||||
get(handlers::get_peers_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/torrents/{hash}/trackers",
|
||||
get(handlers::get_trackers_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/torrents/files/priority",
|
||||
post(handlers::set_file_priority_handler),
|
||||
)
|
||||
.route("/api/torrents/label", post(handlers::set_label_handler))
|
||||
.route(
|
||||
"/api/settings/global-limits",
|
||||
get(handlers::get_global_limit_handler).post(handlers::set_global_limit_handler),
|
||||
)
|
||||
.fallback(handlers::static_handler); // Serve static files for everything else
|
||||
let app = Router::new();
|
||||
|
||||
#[cfg(feature = "push-notifications")]
|
||||
#[cfg(feature = "swagger")]
|
||||
let app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));
|
||||
|
||||
// Setup & Auth Routes (cookie-based, stay as REST)
|
||||
// Setup & Auth Routes (cookie-based, stay as REST)
|
||||
let scgi_path_for_ctx = args.socket.clone();
|
||||
let db_for_ctx = db.clone();
|
||||
let app = app
|
||||
.route("/api/push/public-key", get(handlers::get_push_public_key_handler))
|
||||
.route("/api/push/subscribe", post(handlers::subscribe_push_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({
|
||||
let scgi_path = scgi_path_for_ctx.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
|
||||
.layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware))
|
||||
@@ -459,7 +493,12 @@ async fn main() {
|
||||
}
|
||||
};
|
||||
tracing::info!("Backend listening on {}", addr);
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
if let Err(e) = axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Server error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@ use utoipa::ToSchema;
|
||||
use web_push::{
|
||||
HyperWebPushClient, SubscriptionInfo, VapidSignatureBuilder, WebPushClient, WebPushMessageBuilder,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
|
||||
// VAPID keys - PRODUCTION'DA ENVIRONMENT VARIABLE'DAN ALINMALI!
|
||||
const VAPID_PUBLIC_KEY: &str = "BEdPj6XQR7MGzM28Nev9wokF5upHoydNDahouJbQ9ZdBJpEFAN1iNfANSEvY0ItasNY5zcvvqN_tjUt64Rfd0gU";
|
||||
const VAPID_PRIVATE_KEY: &str = "aUcCYJ7kUd9UClCaWwad0IVgbYJ6svwl19MjSX7GH10";
|
||||
const VAPID_EMAIL: &str = "mailto:admin@vibetorrent.app";
|
||||
use shared::db::Db;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PushSubscription {
|
||||
@@ -23,39 +21,107 @@ pub struct PushKeys {
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
/// In-memory store for push subscriptions
|
||||
/// TODO: Replace with database in production
|
||||
#[derive(Default, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct VapidConfig {
|
||||
pub private_key: String,
|
||||
pub public_key: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PushSubscriptionStore {
|
||||
db: Option<Db>,
|
||||
subscriptions: Arc<RwLock<Vec<PushSubscription>>>,
|
||||
vapid_config: VapidConfig,
|
||||
}
|
||||
|
||||
impl PushSubscriptionStore {
|
||||
pub fn new() -> Self {
|
||||
let private_key = std::env::var("VAPID_PRIVATE_KEY").expect("VAPID_PRIVATE_KEY must be set in .env");
|
||||
let public_key = std::env::var("VAPID_PUBLIC_KEY").expect("VAPID_PUBLIC_KEY must be set in .env");
|
||||
let email = std::env::var("VAPID_EMAIL").expect("VAPID_EMAIL must be set in .env");
|
||||
|
||||
Self {
|
||||
db: None,
|
||||
subscriptions: Arc::new(RwLock::new(Vec::new())),
|
||||
vapid_config: VapidConfig {
|
||||
private_key,
|
||||
public_key,
|
||||
email,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn with_db(db: &Db) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let mut subscriptions_vec: Vec<PushSubscription> = Vec::new();
|
||||
|
||||
// Load existing subscriptions from DB
|
||||
let subs = db.get_all_push_subscriptions().await?;
|
||||
for (endpoint, p256dh, auth) in subs {
|
||||
subscriptions_vec.push(PushSubscription {
|
||||
endpoint,
|
||||
keys: PushKeys { p256dh, auth },
|
||||
});
|
||||
}
|
||||
tracing::info!("Loaded {} push subscriptions from database", subscriptions_vec.len());
|
||||
|
||||
let private_key = std::env::var("VAPID_PRIVATE_KEY").expect("VAPID_PRIVATE_KEY must be set in .env");
|
||||
let public_key = std::env::var("VAPID_PUBLIC_KEY").expect("VAPID_PUBLIC_KEY must be set in .env");
|
||||
let email = std::env::var("VAPID_EMAIL").expect("VAPID_EMAIL must be set in .env");
|
||||
|
||||
Ok(Self {
|
||||
db: Some(db.clone()),
|
||||
subscriptions: Arc::new(RwLock::new(subscriptions_vec)),
|
||||
vapid_config: VapidConfig {
|
||||
private_key,
|
||||
public_key,
|
||||
email,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn add_subscription(&self, subscription: PushSubscription) {
|
||||
// Add to memory
|
||||
let mut subs = self.subscriptions.write().await;
|
||||
|
||||
|
||||
// Remove duplicate endpoint if exists
|
||||
subs.retain(|s| s.endpoint != subscription.endpoint);
|
||||
|
||||
subs.push(subscription);
|
||||
subs.push(subscription.clone());
|
||||
tracing::info!("Added push subscription. Total: {}", subs.len());
|
||||
|
||||
// Save to DB if available
|
||||
if let Some(db) = &self.db {
|
||||
if let Err(e) = db.save_push_subscription(
|
||||
&subscription.endpoint,
|
||||
&subscription.keys.p256dh,
|
||||
&subscription.keys.auth,
|
||||
).await {
|
||||
tracing::error!("Failed to save push subscription to DB: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_subscription(&self, endpoint: &str) {
|
||||
// Remove from memory
|
||||
let mut subs = self.subscriptions.write().await;
|
||||
subs.retain(|s| s.endpoint != endpoint);
|
||||
tracing::info!("Removed push subscription. Total: {}", subs.len());
|
||||
|
||||
// Remove from DB if available
|
||||
if let Some(db) = &self.db {
|
||||
if let Err(e) = db.remove_push_subscription(endpoint).await {
|
||||
tracing::error!("Failed to remove push subscription from DB: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_all_subscriptions(&self) -> Vec<PushSubscription> {
|
||||
self.subscriptions.read().await.clone()
|
||||
}
|
||||
|
||||
pub fn get_public_key(&self) -> &str {
|
||||
&self.vapid_config.public_key
|
||||
}
|
||||
}
|
||||
|
||||
/// Send push notification to all subscribed clients
|
||||
@@ -65,7 +131,7 @@ pub async fn send_push_notification(
|
||||
body: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let subscriptions = store.get_all_subscriptions().await;
|
||||
|
||||
|
||||
if subscriptions.is_empty() {
|
||||
tracing::debug!("No push subscriptions to send to");
|
||||
return Ok(());
|
||||
@@ -81,47 +147,78 @@ pub async fn send_push_notification(
|
||||
"tag": "vibetorrent"
|
||||
});
|
||||
|
||||
let client = HyperWebPushClient::new();
|
||||
let client = Arc::new(HyperWebPushClient::new());
|
||||
let vapid_config = store.vapid_config.clone();
|
||||
let payload_str = payload.to_string();
|
||||
|
||||
for subscription in subscriptions {
|
||||
let subscription_info = SubscriptionInfo {
|
||||
endpoint: subscription.endpoint.clone(),
|
||||
keys: web_push::SubscriptionKeys {
|
||||
p256dh: subscription.keys.p256dh.clone(),
|
||||
auth: subscription.keys.auth.clone(),
|
||||
},
|
||||
};
|
||||
// Send notifications concurrently
|
||||
futures::stream::iter(subscriptions)
|
||||
.for_each_concurrent(10, |subscription| {
|
||||
let client = client.clone();
|
||||
let vapid_config = vapid_config.clone();
|
||||
let payload_str = payload_str.clone();
|
||||
|
||||
let mut sig_builder = VapidSignatureBuilder::from_base64(
|
||||
VAPID_PRIVATE_KEY,
|
||||
web_push::URL_SAFE_NO_PAD,
|
||||
&subscription_info,
|
||||
)?;
|
||||
|
||||
sig_builder.add_claim("sub", VAPID_EMAIL);
|
||||
sig_builder.add_claim("aud", subscription.endpoint.clone());
|
||||
let signature = sig_builder.build()?;
|
||||
async move {
|
||||
let subscription_info = SubscriptionInfo {
|
||||
endpoint: subscription.endpoint.clone(),
|
||||
keys: web_push::SubscriptionKeys {
|
||||
p256dh: subscription.keys.p256dh.clone(),
|
||||
auth: subscription.keys.auth.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
let mut builder = WebPushMessageBuilder::new(&subscription_info);
|
||||
builder.set_vapid_signature(signature);
|
||||
|
||||
let payload_str = payload.to_string();
|
||||
builder.set_payload(web_push::ContentEncoding::Aes128Gcm, payload_str.as_bytes());
|
||||
let sig_res = VapidSignatureBuilder::from_base64(
|
||||
&vapid_config.private_key,
|
||||
web_push::URL_SAFE_NO_PAD,
|
||||
&subscription_info,
|
||||
);
|
||||
|
||||
match client.send(builder.build()?).await {
|
||||
Ok(_) => {
|
||||
tracing::debug!("Push notification sent to: {}", subscription.endpoint);
|
||||
match sig_res {
|
||||
Ok(mut sig_builder) => {
|
||||
sig_builder.add_claim("sub", vapid_config.email.as_str());
|
||||
sig_builder.add_claim("aud", subscription.endpoint.as_str());
|
||||
|
||||
match sig_builder.build() {
|
||||
Ok(signature) => {
|
||||
let mut builder = WebPushMessageBuilder::new(&subscription_info);
|
||||
builder.set_vapid_signature(signature);
|
||||
builder.set_payload(web_push::ContentEncoding::Aes128Gcm, payload_str.as_bytes());
|
||||
|
||||
match builder.build() {
|
||||
Ok(msg) => {
|
||||
match client.send(msg).await {
|
||||
Ok(_) => {
|
||||
tracing::debug!("Push notification sent to: {}", subscription.endpoint);
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("{:?}", 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) => {
|
||||
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 create VAPID signature builder: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to send push notification: {}", e);
|
||||
// TODO: Remove invalid subscriptions
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_vapid_public_key() -> &'static str {
|
||||
VAPID_PUBLIC_KEY
|
||||
}
|
||||
|
||||
3
backend/src/rate_limit.rs
Normal file
3
backend/src/rate_limit.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// This file can be removed or repurposed if rate limiting is needed for other endpoints.
|
||||
// Login rate limiting is now handled within the server function or needs to be reimplemented
|
||||
// as a middleware for the server function endpoint.
|
||||
@@ -1,13 +1,15 @@
|
||||
use crate::xmlrpc::{
|
||||
use shared::xmlrpc::{
|
||||
parse_i64_response, parse_multicall_response, RpcParam, RtorrentClient, XmlRpcError,
|
||||
};
|
||||
use crate::AppState;
|
||||
use axum::extract::State;
|
||||
use axum::response::sse::{Event, Sse};
|
||||
use futures::stream::{self, Stream};
|
||||
use futures::stream::{self};
|
||||
use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus};
|
||||
use std::convert::Infallible;
|
||||
use tokio_stream::StreamExt;
|
||||
use axum::response::IntoResponse;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
|
||||
// Field definitions to keep query and parser in sync
|
||||
mod fields {
|
||||
@@ -49,6 +51,21 @@ mod fields {
|
||||
|
||||
pub const IDX_LABEL: usize = 12;
|
||||
pub const CMD_LABEL: &str = "d.custom1=";
|
||||
|
||||
pub const IDX_RATIO: usize = 13;
|
||||
pub const CMD_RATIO: &str = "d.ratio=";
|
||||
|
||||
pub const IDX_UPLOADED: usize = 14;
|
||||
pub const CMD_UPLOADED: &str = "d.up.total=";
|
||||
|
||||
pub const IDX_WASTED: usize = 15;
|
||||
pub const CMD_WASTED: &str = "d.skip.total=";
|
||||
|
||||
pub const IDX_SAVE_PATH: usize = 16;
|
||||
pub const CMD_SAVE_PATH: &str = "d.base_path=";
|
||||
|
||||
pub const IDX_FREE_DISK: usize = 17;
|
||||
pub const CMD_FREE_DISK: &str = "d.free_diskspace=";
|
||||
}
|
||||
|
||||
use fields::*;
|
||||
@@ -70,6 +87,11 @@ const RTORRENT_FIELDS: &[&str] = &[
|
||||
CMD_CREATION_DATE,
|
||||
CMD_HASHING,
|
||||
CMD_LABEL,
|
||||
CMD_RATIO,
|
||||
CMD_UPLOADED,
|
||||
CMD_WASTED,
|
||||
CMD_SAVE_PATH,
|
||||
CMD_FREE_DISK,
|
||||
];
|
||||
|
||||
fn parse_long(s: Option<&String>) -> i64 {
|
||||
@@ -96,6 +118,11 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
|
||||
let added_date = parse_long(row.get(IDX_CREATION_DATE));
|
||||
let is_hashing = parse_long(row.get(IDX_HASHING));
|
||||
let label_raw = parse_string(row.get(IDX_LABEL));
|
||||
let ratio = parse_long(row.get(IDX_RATIO)) as f64 / 1000.0;
|
||||
let uploaded = parse_long(row.get(IDX_UPLOADED));
|
||||
let wasted = parse_long(row.get(IDX_WASTED));
|
||||
let save_path = parse_string(row.get(IDX_SAVE_PATH));
|
||||
let free_disk_space = parse_long(row.get(IDX_FREE_DISK));
|
||||
|
||||
let label = if label_raw.is_empty() {
|
||||
None
|
||||
@@ -142,6 +169,11 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
|
||||
error_message: message,
|
||||
added_date,
|
||||
label,
|
||||
ratio,
|
||||
uploaded,
|
||||
wasted,
|
||||
save_path,
|
||||
free_disk_space,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +226,10 @@ pub async fn fetch_global_stats(client: &RtorrentClient) -> Result<GlobalStats,
|
||||
|
||||
pub async fn sse_handler(
|
||||
State(state): State<AppState>,
|
||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||
) -> impl IntoResponse {
|
||||
// Notify background worker to wake up and poll immediately
|
||||
state.notify_poll.notify_one();
|
||||
|
||||
// Get initial value synchronously (from the watch channel's current state)
|
||||
let initial_rx = state.tx.subscribe();
|
||||
let initial_torrents = initial_rx.borrow().clone();
|
||||
@@ -205,13 +240,10 @@ pub async fn sse_handler(
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let event_data = AppEvent::FullList {
|
||||
torrents: initial_torrents,
|
||||
timestamp,
|
||||
};
|
||||
let event_data = AppEvent::FullList(initial_torrents, timestamp);
|
||||
|
||||
match serde_json::to_string(&event_data) {
|
||||
Ok(json) => Event::default().data(json),
|
||||
match rmp_serde::to_vec(&event_data) {
|
||||
Ok(bytes) => Event::default().data(BASE64.encode(bytes)),
|
||||
Err(_) => Event::default().comment("init_error"),
|
||||
}
|
||||
};
|
||||
@@ -223,10 +255,10 @@ pub async fn sse_handler(
|
||||
let rx = state.event_bus.subscribe();
|
||||
let update_stream = stream::unfold(rx, |mut rx| async move {
|
||||
match rx.recv().await {
|
||||
Ok(event) => match serde_json::to_string(&event) {
|
||||
Ok(json) => Some((Ok::<Event, Infallible>(Event::default().data(json)), rx)),
|
||||
Ok(event) => match rmp_serde::to_vec(&event) {
|
||||
Ok(bytes) => Some((Ok::<Event, Infallible>(Event::default().data(BASE64.encode(bytes))), rx)),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to serialize SSE event: {}", e);
|
||||
tracing::warn!("Failed to serialize SSE event (MessagePack): {}", e);
|
||||
Some((
|
||||
Ok::<Event, Infallible>(Event::default().comment("error")),
|
||||
rx,
|
||||
@@ -241,6 +273,11 @@ pub async fn sse_handler(
|
||||
}
|
||||
});
|
||||
|
||||
Sse::new(initial_stream.chain(update_stream))
|
||||
.keep_alive(axum::response::sse::KeepAlive::default())
|
||||
}
|
||||
let sse = Sse::new(initial_stream.chain(update_stream))
|
||||
.keep_alive(axum::response::sse::KeepAlive::default());
|
||||
|
||||
(
|
||||
[("content-type", "text/event-stream")],
|
||||
sse
|
||||
)
|
||||
}
|
||||
0
backend_log.txt
Normal file
0
backend_log.txt
Normal file
@@ -20,6 +20,8 @@ RUN apt-get update && apt-get install -y \
|
||||
jq \
|
||||
# Needed for some crate compilations
|
||||
protobuf-compiler \
|
||||
# Install binaryen to have wasm-opt available system-wide
|
||||
binaryen \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 2. Install Node.js v20 (Manual install to support multi-arch cleanly)
|
||||
@@ -70,7 +72,7 @@ RUN . "$HOME/.cargo/env" && \
|
||||
ARCH=$(dpkg --print-architecture) && \
|
||||
if [ "$ARCH" = "amd64" ]; then TRUNK_ARCH="x86_64-unknown-linux-gnu"; \
|
||||
elif [ "$ARCH" = "arm64" ]; then TRUNK_ARCH="aarch64-unknown-linux-gnu"; fi && \
|
||||
wget -qO- "https://github.com/trunk-rs/trunk/releases/download/v0.21.5/trunk-$TRUNK_ARCH.tar.gz" | tar -xzf - -C /root/.cargo/bin/ && \
|
||||
wget -qO- "https://github.com/trunk-rs/trunk/releases/download/v0.21.14/trunk-$TRUNK_ARCH.tar.gz" | tar -xzf - -C /root/.cargo/bin/ && \
|
||||
chmod +x /root/.cargo/bin/trunk && \
|
||||
# Install wasm-bindgen-cli (Compiling from source to avoid glibc issues, doing it ONCE here)
|
||||
cargo install wasm-bindgen-cli --version 0.2.108
|
||||
|
||||
1
frontend/.gitignore
vendored
Normal file
1
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -7,48 +7,39 @@ edition = "2021"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
leptos = { version = "0.6", features = ["csr"] }
|
||||
leptos_router = { version = "0.6", features = ["csr"] }
|
||||
leptos = { version = "0.8.15", features = ["csr", "msgpack", "nightly"] }
|
||||
leptos_router = { version = "0.8.11" }
|
||||
|
||||
console_error_panic_hook = "0.1"
|
||||
console_log = "1"
|
||||
log = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
gloo-net = "0.5"
|
||||
gloo-net = "0.6"
|
||||
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||
gloo-console = "0.3"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
uuid = { version = "1", features = ["v4", "js"] }
|
||||
futures = "0.3"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
web-sys = { version = "0.3", features = [
|
||||
"HtmlDivElement",
|
||||
"HtmlUListElement",
|
||||
"HtmlLiElement",
|
||||
"HtmlAnchorElement",
|
||||
"MouseEvent",
|
||||
"Event",
|
||||
"Window",
|
||||
"Document",
|
||||
"Element",
|
||||
"DomTokenList",
|
||||
"CssStyleDeclaration",
|
||||
"Storage",
|
||||
"TouchEvent",
|
||||
"TouchList",
|
||||
"Touch",
|
||||
"Navigator",
|
||||
"Notification",
|
||||
"NotificationOptions",
|
||||
"NotificationPermission",
|
||||
"ServiceWorkerContainer",
|
||||
"ServiceWorkerRegistration",
|
||||
"PushManager",
|
||||
"PushSubscription",
|
||||
"PushSubscriptionOptions",
|
||||
"PushSubscriptionOptionsInit"
|
||||
] }
|
||||
shared = { path = "../shared" }
|
||||
chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }
|
||||
web-sys = { version = "0.3", features = ["HtmlDivElement", "HtmlUListElement", "HtmlLiElement", "HtmlAnchorElement", "MouseEvent", "Event", "Window", "Document", "Element", "DomTokenList", "CssStyleDeclaration", "Storage", "TouchEvent", "TouchList", "Touch", "Navigator", "Notification", "NotificationOptions", "NotificationPermission", "ServiceWorkerContainer", "ServiceWorkerRegistration", "PushManager", "PushSubscription", "PushSubscriptionOptions", "PushSubscriptionOptionsInit", "HtmlDetailsElement", "HtmlInputElement", "HtmlFormElement", "HtmlDialogElement", "ProgressEvent"] }
|
||||
shared = { path = "../shared", features = ["hydrate"] }
|
||||
tailwind_fuse = "0.3.2"
|
||||
js-sys = "0.3.85"
|
||||
base64 = "0.22.1"
|
||||
serde-wasm-bindgen = "0.6.5"
|
||||
leptos-use = { version = "0.16", features = ["storage"] }
|
||||
codee = "0.3"
|
||||
thiserror = "2.0"
|
||||
rmp-serde = "1.3"
|
||||
struct-patch = "0.5"
|
||||
|
||||
# Rust/UI Components
|
||||
leptos_ui = "0.3"
|
||||
tw_merge = "0.1"
|
||||
strum = { version = "0.26", features = ["derive"] }
|
||||
icons = { version = "0.18.0", features = ["leptos"] }
|
||||
|
||||
[package.metadata.leptos]
|
||||
tailwind-input-file = "input.css"
|
||||
|
||||
@@ -1,98 +1,103 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>VibeTorrent</title>
|
||||
|
||||
<!-- PWA & Mobile Capable -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="VibeTorrent" />
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<link rel="icon" type="image/png" href="icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="icon-192.png" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
|
||||
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>VibeTorrent</title>
|
||||
|
||||
<!-- Trunk Assets -->
|
||||
<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="copy-file" href="manifest.json" />
|
||||
<link data-trunk rel="copy-file" href="icon-192.png" />
|
||||
<link data-trunk rel="copy-file" href="icon-512.png" />
|
||||
<link data-trunk rel="copy-file" href="sw.js" />
|
||||
<script>
|
||||
(function () {
|
||||
var localTheme = localStorage.getItem("vibetorrent_theme");
|
||||
var t = localTheme || "dark";
|
||||
if (t === "Amoled") t = "black";
|
||||
if (t === "Light") t = "light";
|
||||
if (t === "Dark" || t === "Midnight") t = "dark";
|
||||
<!-- PWA & Mobile Capable -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="VibeTorrent" />
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<link rel="icon" type="image/png" href="icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="icon-192.png" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
|
||||
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
|
||||
|
||||
var theme = t.toLowerCase();
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
if (!localTheme) {
|
||||
localStorage.setItem("vibetorrent_theme", "dark");
|
||||
}
|
||||
<!-- Trunk Assets -->
|
||||
<script data-trunk rel="rust" src="Cargo.toml" data-wasm-opt="0" data-preload="false"></script>
|
||||
<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="icon-192.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" />
|
||||
<script>
|
||||
(function () {
|
||||
var localTheme = localStorage.getItem("vibetorrent_theme");
|
||||
var t = localTheme || "dark";
|
||||
if (t === "Amoled") t = "black";
|
||||
if (t === "Light") t = "light";
|
||||
if (t === "Dark" || t === "Midnight") t = "dark";
|
||||
|
||||
var meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) {
|
||||
var colorMap = {
|
||||
light: "#ffffff",
|
||||
cupcake: "#faf7f5",
|
||||
bumblebee: "#ffffff",
|
||||
emerald: "#ffffff",
|
||||
corporate: "#ffffff",
|
||||
synthwave: "#2d1b69",
|
||||
retro: "#ece3ca",
|
||||
cyberpunk: "#ffee00",
|
||||
valentine: "#f0d6e8",
|
||||
halloween: "#212121",
|
||||
garden: "#e9e7e7",
|
||||
forest: "#171212",
|
||||
aqua: "#345da7",
|
||||
lofi: "#ffffff",
|
||||
pastel: "#ffffff",
|
||||
fantasy: "#ffffff",
|
||||
wireframe: "#ffffff",
|
||||
black: "#000000",
|
||||
luxury: "#09090b",
|
||||
dracula: "#282a36",
|
||||
cmyk: "#ffffff",
|
||||
autumn: "#8C0327",
|
||||
business: "#202020",
|
||||
acid: "#fafafa",
|
||||
lemonade: "#F1F8E8",
|
||||
night: "#0f1729",
|
||||
coffee: "#20161f",
|
||||
winter: "#ffffff",
|
||||
dark: "#1d232a",
|
||||
};
|
||||
var color = colorMap[theme] || "#1d232a";
|
||||
meta.setAttribute("content", color);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
var theme = t.toLowerCase();
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
// Shadcn dark mode CSS değişkenleri .dark class ile çalışıyor
|
||||
var darkThemes = ["dark", "black", "night", "coffee", "luxury", "business", "dracula", "halloween", "forest", "synthwave", "dim", "nord", "sunset", "cyberpunk", "abyss"];
|
||||
if (darkThemes.indexOf(theme) !== -1) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
if (!localTheme) {
|
||||
localStorage.setItem("vibetorrent_theme", "dark");
|
||||
}
|
||||
|
||||
<body>
|
||||
<div
|
||||
id="app-loading"
|
||||
style="
|
||||
var meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) {
|
||||
var colorMap = {
|
||||
light: "#ffffff",
|
||||
cupcake: "#faf7f5",
|
||||
bumblebee: "#ffffff",
|
||||
emerald: "#ffffff",
|
||||
corporate: "#ffffff",
|
||||
synthwave: "#2d1b69",
|
||||
retro: "#ece3ca",
|
||||
cyberpunk: "#ffee00",
|
||||
valentine: "#f0d6e8",
|
||||
halloween: "#212121",
|
||||
garden: "#e9e7e7",
|
||||
forest: "#171212",
|
||||
aqua: "#345da7",
|
||||
lofi: "#ffffff",
|
||||
pastel: "#ffffff",
|
||||
fantasy: "#ffffff",
|
||||
wireframe: "#ffffff",
|
||||
black: "#000000",
|
||||
luxury: "#09090b",
|
||||
dracula: "#282a36",
|
||||
cmyk: "#ffffff",
|
||||
autumn: "#8C0327",
|
||||
business: "#202020",
|
||||
acid: "#fafafa",
|
||||
lemonade: "#F1F8E8",
|
||||
night: "#0f1729",
|
||||
coffee: "#20161f",
|
||||
winter: "#ffffff",
|
||||
dark: "#1d232a",
|
||||
};
|
||||
var color = colorMap[theme] || "#1d232a";
|
||||
meta.setAttribute("content", color);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body style="cursor: pointer;">
|
||||
<div id="app-loading" style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
font-family: sans-serif;
|
||||
">
|
||||
<div id="app-loading-spinner" style="
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid currentColor;
|
||||
@@ -100,49 +105,122 @@
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
opacity: 0.5;
|
||||
"
|
||||
></div>
|
||||
"></div>
|
||||
<div id="app-loading-error" style="display: none; text-align: center; margin-top: 20px; padding: 0 20px">
|
||||
<p style="color: #ef4444; font-weight: bold; margin-bottom: 8px">
|
||||
Uygulama yüklenemedi
|
||||
</p>
|
||||
<p style="font-size: 14px; opacity: 0.7">
|
||||
Bağlantınız yavaş olabilir veya bir sistem hatası oluşmuş olabilir.
|
||||
</p>
|
||||
<button onclick="location.reload()" style="
|
||||
margin-top: 16px;
|
||||
padding: 8px 16px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
">
|
||||
Sayfayı Yenile
|
||||
</button>
|
||||
</div>
|
||||
<style>
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
</div>
|
||||
<style>
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
body.app-loaded #app-loading {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Service Worker Registration & PWA Setup -->
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
.then((registration) => {
|
||||
console.log("✅ Service Worker registered:", registration);
|
||||
|
||||
// Request notification permission after a delay (better UX)
|
||||
setTimeout(() => {
|
||||
if ("Notification" in window && Notification.permission === "default") {
|
||||
// Only request if user hasn't decided yet
|
||||
const shouldRequest = localStorage.getItem("vibetorrent_notification_prompt_shown");
|
||||
if (!shouldRequest) {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
console.log("Notification permission:", permission);
|
||||
localStorage.setItem("vibetorrent_notification_prompt_shown", "true");
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 3000); // Wait 3 seconds before asking
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("⚠️ Service Worker registration failed:", error);
|
||||
});
|
||||
body.app-loaded #app-loading {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* iOS Safari Click Fixes */
|
||||
body {
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// App loading timeout handler
|
||||
(function () {
|
||||
var timeout = setTimeout(function () {
|
||||
if (!document.body.classList.contains("app-loaded")) {
|
||||
var spinner = document.getElementById("app-loading-spinner");
|
||||
var error = document.getElementById("app-loading-error");
|
||||
if (spinner) spinner.style.display = "none";
|
||||
if (error) error.style.display = "block";
|
||||
}
|
||||
}, 15000); // 15 seconds timeout
|
||||
|
||||
// Clean up timeout if app loads
|
||||
var observer = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (mutation) {
|
||||
if (
|
||||
mutation.attributeName === "class" &&
|
||||
document.body.classList.contains("app-loaded")
|
||||
) {
|
||||
clearTimeout(timeout);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
});
|
||||
observer.observe(document.body, { attributes: true });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Service Worker Registration & PWA Setup -->
|
||||
<script>
|
||||
// Global Dropdown Closer for iOS/Mobile
|
||||
document.addEventListener('click', function (event) {
|
||||
const details = document.querySelectorAll('details[open]');
|
||||
details.forEach(detail => {
|
||||
// Eğer tıklanan yer bu details'in içinde değilse kapat
|
||||
if (!detail.contains(event.target)) {
|
||||
detail.removeAttribute('open');
|
||||
}
|
||||
});
|
||||
}, true); // Use capture phase for better mobile support
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
.then((registration) => {
|
||||
console.log("✅ Service Worker registered:", registration);
|
||||
|
||||
// Request notification permission after a delay (better UX)
|
||||
setTimeout(() => {
|
||||
if ("Notification" in window && Notification.permission === "default") {
|
||||
// Only request if user hasn't decided yet
|
||||
const shouldRequest = localStorage.getItem("vibetorrent_notification_prompt_shown");
|
||||
if (!shouldRequest) {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
console.log("Notification permission:", permission);
|
||||
localStorage.setItem("vibetorrent_notification_prompt_shown", "true");
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 3000); // Wait 3 seconds before asking
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("⚠️ Service Worker registration failed:", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,32 +1,99 @@
|
||||
@import "tailwindcss";
|
||||
@config "./tailwind.config.js";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@plugin "daisyui" {
|
||||
themes:
|
||||
light, dark, dim, nord, cupcake, dracula, cyberpunk, emerald, sunset,
|
||||
abyss;
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--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 {
|
||||
--background: oklch(0.145 0 0);
|
||||
--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);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--animate-shimmer: shimmer 2s infinite;
|
||||
|
||||
@keyframes shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
@apply min-h-dvh w-full overflow-hidden bg-base-100 text-base-content overscroll-y-none;
|
||||
}
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
/* Fix for iOS click/blur events */
|
||||
@media (hover: none) {
|
||||
body {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* Remove default focus outline/tap highlight */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none !important;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
dialog {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
2650
frontend/package-lock.json
generated
2650
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,29 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"author": "",
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-cli": "^11.0.1",
|
||||
"postcss-preset-env": "^10.1.3",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"keywords": [],
|
||||
"license": "ISC",
|
||||
"main": "tailwind.config.js",
|
||||
"name": "frontend",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.23",
|
||||
"daisyui": "^5.5.1-beta.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4.1.18"
|
||||
}
|
||||
"type": "module",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
15
frontend/postcss.config.cjs
Normal file
15
frontend/postcss.config.cjs
Normal file
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
"postcss-preset-env": {
|
||||
features: {
|
||||
"nesting-rules": true,
|
||||
},
|
||||
browsers: [
|
||||
"last 2 versions",
|
||||
"iOS >= 15",
|
||||
"Safari >= 15",
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
253
frontend/public/lock_scroll.js
Normal file
253
frontend/public/lock_scroll.js
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Scroll Lock Utility
|
||||
* Handles locking and unlocking scroll for both window and all scrollable containers
|
||||
* Similar to react-remove-scroll but in vanilla JavaScript
|
||||
*/
|
||||
|
||||
(() => {
|
||||
// Prevent multiple initializations
|
||||
if (window.ScrollLock) {
|
||||
return;
|
||||
}
|
||||
|
||||
class ScrollLock {
|
||||
constructor() {
|
||||
this.locked = false;
|
||||
this.scrollableElements = [];
|
||||
this.scrollPositions = new Map();
|
||||
this.originalStyles = new Map();
|
||||
this.fixedElements = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all scrollable elements in the DOM (optimized)
|
||||
* Uses more targeted selectors instead of querying all elements
|
||||
*/
|
||||
findScrollableElements() {
|
||||
const scrollables = [];
|
||||
|
||||
// More targeted query - only look for elements with overflow properties
|
||||
const candidates = document.querySelectorAll(
|
||||
'[style*="overflow"], [class*="overflow"], [class*="scroll"], main, aside, section, div',
|
||||
);
|
||||
|
||||
// Batch all style reads first to minimize reflows
|
||||
const elementsToCheck = [];
|
||||
for (const el of candidates) {
|
||||
// Skip the element itself or if it's inside these containers
|
||||
const dataName = el.getAttribute("data-name");
|
||||
const isExcludedElement =
|
||||
dataName === "ScrollArea" ||
|
||||
dataName === "CommandList" ||
|
||||
dataName === "SelectContent" ||
|
||||
dataName === "MultiSelectContent" ||
|
||||
dataName === "DropdownMenuContent" ||
|
||||
dataName === "ContextMenuContent";
|
||||
|
||||
if (
|
||||
el !== document.body &&
|
||||
el !== document.documentElement &&
|
||||
!isExcludedElement &&
|
||||
!el.closest('[data-name="ScrollArea"]') &&
|
||||
!el.closest('[data-name="CommandList"]') &&
|
||||
!el.closest('[data-name="SelectContent"]') &&
|
||||
!el.closest('[data-name="MultiSelectContent"]') &&
|
||||
!el.closest('[data-name="DropdownMenuContent"]') &&
|
||||
!el.closest('[data-name="ContextMenuContent"]')
|
||||
) {
|
||||
elementsToCheck.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
// Now batch read all computed styles and dimensions
|
||||
elementsToCheck.forEach((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const hasOverflow =
|
||||
style.overflow === "auto" ||
|
||||
style.overflow === "scroll" ||
|
||||
style.overflowY === "auto" ||
|
||||
style.overflowY === "scroll";
|
||||
|
||||
// Only check scrollHeight if overflow is set
|
||||
if (hasOverflow && el.scrollHeight > el.clientHeight) {
|
||||
scrollables.push(el);
|
||||
}
|
||||
});
|
||||
|
||||
return scrollables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock scrolling on all scrollable elements (optimized)
|
||||
* Batches all DOM reads before DOM writes to prevent forced reflows
|
||||
*/
|
||||
lock() {
|
||||
if (this.locked) return;
|
||||
|
||||
this.locked = true;
|
||||
|
||||
// Find all scrollable elements
|
||||
this.scrollableElements = this.findScrollableElements();
|
||||
|
||||
// ===== BATCH 1: READ PHASE - Read all layout properties first =====
|
||||
const windowScrollY = window.scrollY;
|
||||
const scrollbarWidth = window.innerWidth - document.body.clientWidth;
|
||||
|
||||
// Store window scroll position
|
||||
this.scrollPositions.set("window", windowScrollY);
|
||||
|
||||
// Store original body styles
|
||||
this.originalStyles.set("body", {
|
||||
position: document.body.style.position,
|
||||
top: document.body.style.top,
|
||||
width: document.body.style.width,
|
||||
overflow: document.body.style.overflow,
|
||||
paddingRight: document.body.style.paddingRight,
|
||||
});
|
||||
|
||||
// Read all fixed-position elements and their padding (only if we have scrollbar)
|
||||
if (scrollbarWidth > 0) {
|
||||
// Use more targeted query for fixed elements
|
||||
const fixedCandidates = document.querySelectorAll(
|
||||
'[style*="fixed"], [class*="fixed"], header, nav, aside, [role="dialog"], [role="alertdialog"]',
|
||||
);
|
||||
|
||||
this.fixedElements = Array.from(fixedCandidates).filter((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return (
|
||||
style.position === "fixed" &&
|
||||
!el.closest('[data-name="DropdownMenuContent"]') &&
|
||||
!el.closest('[data-name="MultiSelectContent"]') &&
|
||||
!el.closest('[data-name="ContextMenuContent"]')
|
||||
);
|
||||
});
|
||||
|
||||
// Batch read all padding values
|
||||
this.fixedElements.forEach((el) => {
|
||||
const computedStyle = window.getComputedStyle(el);
|
||||
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
|
||||
|
||||
this.originalStyles.set(el, {
|
||||
paddingRight: el.style.paddingRight,
|
||||
computedPadding: currentPadding,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Read scrollable elements info
|
||||
const scrollableInfo = this.scrollableElements.map((el) => {
|
||||
const scrollTop = el.scrollTop;
|
||||
const elementScrollbarWidth = el.offsetWidth - el.clientWidth;
|
||||
const computedStyle = window.getComputedStyle(el);
|
||||
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
|
||||
|
||||
this.scrollPositions.set(el, scrollTop);
|
||||
this.originalStyles.set(el, {
|
||||
overflow: el.style.overflow,
|
||||
overflowY: el.style.overflowY,
|
||||
paddingRight: el.style.paddingRight,
|
||||
});
|
||||
|
||||
return { el, elementScrollbarWidth, currentPadding };
|
||||
});
|
||||
|
||||
// ===== BATCH 2: WRITE PHASE - Apply all styles at once =====
|
||||
|
||||
// Apply body lock
|
||||
document.body.style.position = "fixed";
|
||||
document.body.style.top = `-${windowScrollY}px`;
|
||||
document.body.style.width = "100%";
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
if (scrollbarWidth > 0) {
|
||||
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||
|
||||
// Apply padding compensation to fixed elements
|
||||
this.fixedElements.forEach((el) => {
|
||||
const stored = this.originalStyles.get(el);
|
||||
if (stored) {
|
||||
el.style.paddingRight = `${stored.computedPadding + scrollbarWidth}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Lock all scrollable containers
|
||||
scrollableInfo.forEach(({ el, elementScrollbarWidth, currentPadding }) => {
|
||||
el.style.overflow = "hidden";
|
||||
|
||||
if (elementScrollbarWidth > 0) {
|
||||
el.style.paddingRight = `${currentPadding + elementScrollbarWidth}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock scrolling on all elements (optimized)
|
||||
* @param {number} delay - Delay in milliseconds before unlocking (for animations)
|
||||
*/
|
||||
unlock(delay = 0) {
|
||||
if (!this.locked) return;
|
||||
|
||||
const performUnlock = () => {
|
||||
// Restore body scroll
|
||||
const bodyStyles = this.originalStyles.get("body");
|
||||
if (bodyStyles) {
|
||||
document.body.style.position = bodyStyles.position;
|
||||
document.body.style.top = bodyStyles.top;
|
||||
document.body.style.width = bodyStyles.width;
|
||||
document.body.style.overflow = bodyStyles.overflow;
|
||||
document.body.style.paddingRight = bodyStyles.paddingRight;
|
||||
}
|
||||
|
||||
// Restore window scroll position
|
||||
const windowScrollY = this.scrollPositions.get("window") || 0;
|
||||
window.scrollTo(0, windowScrollY);
|
||||
|
||||
// Restore all scrollable containers
|
||||
this.scrollableElements.forEach((el) => {
|
||||
const originalStyles = this.originalStyles.get(el);
|
||||
if (originalStyles) {
|
||||
el.style.overflow = originalStyles.overflow;
|
||||
el.style.overflowY = originalStyles.overflowY;
|
||||
el.style.paddingRight = originalStyles.paddingRight;
|
||||
}
|
||||
|
||||
// Restore scroll position
|
||||
const scrollPosition = this.scrollPositions.get(el) || 0;
|
||||
el.scrollTop = scrollPosition;
|
||||
});
|
||||
|
||||
// Restore fixed-position elements padding
|
||||
this.fixedElements.forEach((el) => {
|
||||
const styles = this.originalStyles.get(el);
|
||||
if (styles && styles.paddingRight !== undefined) {
|
||||
el.style.paddingRight = styles.paddingRight;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear storage
|
||||
this.scrollableElements = [];
|
||||
this.fixedElements = [];
|
||||
this.scrollPositions.clear();
|
||||
this.originalStyles.clear();
|
||||
this.locked = false;
|
||||
};
|
||||
|
||||
if (delay > 0) {
|
||||
setTimeout(performUnlock, delay);
|
||||
} else {
|
||||
performUnlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if scrolling is currently locked
|
||||
*/
|
||||
isLocked() {
|
||||
return this.locked;
|
||||
}
|
||||
}
|
||||
|
||||
// Export as singleton
|
||||
window.ScrollLock = new ScrollLock();
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
210
frontend/src/api/mod.rs
Normal file
210
frontend/src/api/mod.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
use gloo_net::http::Request;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
#[error("Network error")]
|
||||
Network,
|
||||
#[error("Server error: {status}")]
|
||||
Server { status: u16 },
|
||||
#[error("Login failed")]
|
||||
LoginFailed,
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("Too many requests")]
|
||||
RateLimited,
|
||||
#[error("Server function error: {0}")]
|
||||
ServerFn(String),
|
||||
}
|
||||
|
||||
fn base_url() -> String {
|
||||
"/api".to_string()
|
||||
}
|
||||
|
||||
pub mod auth {
|
||||
use super::*;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub remember_me: bool,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
username: &str,
|
||||
password: &str,
|
||||
remember_me: bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let req = LoginRequest {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
remember_me,
|
||||
};
|
||||
let resp = Request::post(&format!("{}/auth/login", base_url()))
|
||||
.json(&req)
|
||||
.map_err(|_| ApiError::Network)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ApiError::Network)?;
|
||||
|
||||
if resp.ok() {
|
||||
Ok(())
|
||||
} else if resp.status() == 429 {
|
||||
Err(ApiError::RateLimited)
|
||||
} else {
|
||||
Err(ApiError::LoginFailed)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout() -> Result<(), ApiError> {
|
||||
Request::post(&format!("{}/auth/logout", base_url()))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ApiError::Network)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_auth() -> Result<bool, ApiError> {
|
||||
let resp = Request::get(&format!("{}/auth/check", base_url()))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ApiError::Network)?;
|
||||
Ok(resp.ok())
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct UserResponse {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
pub async fn get_user() -> Result<UserResponse, ApiError> {
|
||||
let resp = Request::get(&format!("{}/auth/check", base_url()))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ApiError::Network)?;
|
||||
let user = resp.json().await.map_err(|_| ApiError::Network)?;
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
pub mod setup {
|
||||
use super::*;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct SetupRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SetupStatusResponse {
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
pub async fn get_status() -> Result<SetupStatusResponse, ApiError> {
|
||||
let resp = Request::get(&format!("{}/setup/status", base_url()))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ApiError::Network)?;
|
||||
let status = resp.json().await.map_err(|_| ApiError::Network)?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
pub async fn setup(username: &str, password: &str) -> Result<(), ApiError> {
|
||||
let req = SetupRequest {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
Request::post(&format!("{}/setup", base_url()))
|
||||
.json(&req)
|
||||
.map_err(|_| ApiError::Network)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ApiError::Network)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub mod settings {
|
||||
use super::*;
|
||||
use shared::GlobalLimitRequest;
|
||||
|
||||
pub async fn set_global_limits(req: &GlobalLimitRequest) -> Result<(), ApiError> {
|
||||
shared::server_fns::settings::set_global_limits(
|
||||
req.max_download_rate,
|
||||
req.max_upload_rate,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::ServerFn(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub mod push {
|
||||
use super::*;
|
||||
|
||||
pub async fn get_public_key() -> Result<String, ApiError> {
|
||||
shared::server_fns::push::get_public_key()
|
||||
.await
|
||||
.map_err(|e| ApiError::ServerFn(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn subscribe(endpoint: &str, p256dh: &str, auth: &str) -> Result<(), ApiError> {
|
||||
shared::server_fns::push::subscribe_push(
|
||||
endpoint.to_string(),
|
||||
p256dh.to_string(),
|
||||
auth.to_string(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::ServerFn(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub mod torrent {
|
||||
use super::*;
|
||||
|
||||
pub async fn add(uri: &str) -> Result<(), ApiError> {
|
||||
shared::server_fns::torrent::add_torrent(uri.to_string())
|
||||
.await
|
||||
.map_err(|e| ApiError::ServerFn(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn action(hash: &str, action: &str) -> Result<(), ApiError> {
|
||||
shared::server_fns::torrent::torrent_action(hash.to_string(), action.to_string())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| ApiError::ServerFn(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn delete(hash: &str) -> Result<(), ApiError> {
|
||||
action(hash, "delete").await
|
||||
}
|
||||
|
||||
pub async fn delete_with_data(hash: &str) -> Result<(), ApiError> {
|
||||
action(hash, "delete_with_data").await
|
||||
}
|
||||
|
||||
pub async fn start(hash: &str) -> Result<(), ApiError> {
|
||||
action(hash, "start").await
|
||||
}
|
||||
|
||||
pub async fn stop(hash: &str) -> Result<(), ApiError> {
|
||||
action(hash, "stop").await
|
||||
}
|
||||
|
||||
pub async fn set_label(hash: &str, label: &str) -> Result<(), ApiError> {
|
||||
shared::server_fns::torrent::set_label(hash.to_string(), label.to_string())
|
||||
.await
|
||||
.map_err(|e| ApiError::ServerFn(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn set_priority(hash: &str, file_index: u32, priority: u8) -> Result<(), ApiError> {
|
||||
shared::server_fns::torrent::set_file_priority(
|
||||
hash.to_string(),
|
||||
file_index,
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::ServerFn(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -1,152 +1,221 @@
|
||||
use crate::components::layout::sidebar::Sidebar;
|
||||
use crate::components::layout::statusbar::StatusBar;
|
||||
use crate::components::layout::toolbar::Toolbar;
|
||||
use crate::components::toast::ToastContainer;
|
||||
use crate::components::layout::protected::Protected;
|
||||
use crate::components::ui::skeleton::Skeleton;
|
||||
use crate::components::torrent::table::TorrentTable;
|
||||
use crate::components::auth::login::Login;
|
||||
use crate::components::auth::setup::Setup;
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetupStatus {
|
||||
completed: bool,
|
||||
}
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_router::components::{Router, Routes, Route};
|
||||
use leptos_router::hooks::{use_navigate, use_location};
|
||||
use crate::components::ui::toast::Toaster;
|
||||
use crate::components::hooks::use_theme_mode::ThemeMode;
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
crate::components::ui::toast::provide_toaster();
|
||||
let theme_mode = ThemeMode::init();
|
||||
|
||||
// Sync theme with document
|
||||
Effect::new(move |_| {
|
||||
let is_dark = theme_mode.get();
|
||||
if let Some(doc) = document().document_element() {
|
||||
if is_dark {
|
||||
let _ = doc.class_list().add_1("dark");
|
||||
let _ = doc.set_attribute("data-theme", "dark");
|
||||
} else {
|
||||
let _ = doc.class_list().remove_1("dark");
|
||||
let _ = doc.set_attribute("data-theme", "light");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<Toaster />
|
||||
<Router>
|
||||
<InnerApp />
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn InnerApp() -> impl IntoView {
|
||||
crate::store::provide_torrent_store();
|
||||
let store = use_context::<crate::store::TorrentStore>();
|
||||
let _loc = use_location();
|
||||
|
||||
// Auth State
|
||||
let (is_loading, set_is_loading) = create_signal(true);
|
||||
let (is_authenticated, set_is_authenticated) = create_signal(false);
|
||||
let is_loading = signal(true);
|
||||
let is_authenticated = signal(false);
|
||||
let needs_setup = signal(false);
|
||||
|
||||
// Check Auth & Setup Status on load
|
||||
create_effect(move |_| {
|
||||
Effect::new(move |_| {
|
||||
spawn_local(async move {
|
||||
// 1. Check Setup Status
|
||||
let setup_res = gloo_net::http::Request::get("/api/setup/status").send().await;
|
||||
if let Ok(resp) = setup_res {
|
||||
if let Ok(status) = resp.json::<SetupStatus>().await {
|
||||
log::info!("App initialization started...");
|
||||
gloo_console::log!("APP INIT: Checking setup status...");
|
||||
|
||||
// Check if setup is needed via Server Function
|
||||
match shared::server_fns::auth::get_setup_status().await {
|
||||
Ok(status) => {
|
||||
if !status.completed {
|
||||
// Redirect to setup if not completed
|
||||
let navigate = use_navigate();
|
||||
navigate("/setup", Default::default());
|
||||
set_is_loading.set(false);
|
||||
log::info!("Setup not completed");
|
||||
needs_setup.1.set(true);
|
||||
is_loading.1.set(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("Failed to get setup status: {:?}", e),
|
||||
}
|
||||
|
||||
// 2. Check Auth Status
|
||||
let auth_res = gloo_net::http::Request::get("/api/auth/check").send().await;
|
||||
if let Ok(resp) = auth_res {
|
||||
if resp.status() == 200 {
|
||||
set_is_authenticated.set(true);
|
||||
|
||||
// Initialize push notifications logic only if authenticated
|
||||
// ... (Push notification logic moved here or kept global but guarded)
|
||||
} else {
|
||||
let navigate = use_navigate();
|
||||
// If we are already on login or setup, don't redirect loop
|
||||
let pathname = window().location().pathname().unwrap_or_default();
|
||||
if pathname != "/login" && pathname != "/setup" {
|
||||
navigate("/login", Default::default());
|
||||
// Check authentication via GetUser Server Function
|
||||
match shared::server_fns::auth::get_user().await {
|
||||
Ok(Some(user_info)) => {
|
||||
log::info!("Authenticated as {}", user_info.username);
|
||||
if let Some(s) = store {
|
||||
s.user.set(Some(user_info.username));
|
||||
}
|
||||
is_authenticated.1.set(true);
|
||||
}
|
||||
Ok(None) => {
|
||||
log::info!("Not authenticated");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Auth check failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
set_is_loading.set(false);
|
||||
|
||||
is_loading.1.set(false);
|
||||
crate::store::toast_success("VibeTorrent'e Hoşgeldiniz");
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize push notifications after user grants permission (Only if authenticated)
|
||||
create_effect(move |_| {
|
||||
if is_authenticated.get() {
|
||||
Effect::new(move |_| {
|
||||
if is_authenticated.0.get() {
|
||||
spawn_local(async {
|
||||
// Wait a bit for service worker to be ready
|
||||
gloo_timers::future::TimeoutFuture::new(2000).await;
|
||||
|
||||
// Check if running on iOS and not standalone
|
||||
if let Some(ios_message) = crate::utils::platform::get_ios_notification_info() {
|
||||
log::warn!("iOS detected: {}", ios_message);
|
||||
if let Some(store) = use_context::<crate::store::TorrentStore>() {
|
||||
crate::store::show_toast_with_signal(
|
||||
store.notifications,
|
||||
shared::NotificationLevel::Info,
|
||||
ios_message,
|
||||
);
|
||||
}
|
||||
return;
|
||||
if crate::utils::platform::supports_push_notifications() && !crate::utils::platform::is_safari() {
|
||||
crate::store::subscribe_to_push_notifications().await;
|
||||
}
|
||||
|
||||
if !crate::utils::platform::supports_push_notifications() {
|
||||
return;
|
||||
}
|
||||
|
||||
if crate::utils::platform::is_safari() {
|
||||
if let Some(store) = use_context::<crate::store::TorrentStore>() {
|
||||
crate::store::show_toast_with_signal(
|
||||
store.notifications,
|
||||
shared::NotificationLevel::Info,
|
||||
"Bildirim izni için sağ alttaki ayarlar ⚙️ ikonuna basın.".to_string(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
crate::store::subscribe_to_push_notifications().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="relative w-full h-screen" style="height: 100dvh;">
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" view=move || view! { <Login /> } />
|
||||
<Route path="/setup" view=move || view! { <Setup /> } />
|
||||
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
|
||||
<Route path=leptos_router::path!("/login") view=move || {
|
||||
let authenticated = is_authenticated.0.get();
|
||||
let setup_needed = needs_setup.0.get();
|
||||
|
||||
Effect::new(move |_| {
|
||||
if setup_needed {
|
||||
let navigate = use_navigate();
|
||||
navigate("/setup", Default::default());
|
||||
} else if authenticated {
|
||||
log::info!("Already authenticated, redirecting to home");
|
||||
let navigate = use_navigate();
|
||||
navigate("/", Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
view! { <Login /> }
|
||||
} />
|
||||
<Route path=leptos_router::path!("/setup") view=move || {
|
||||
Effect::new(move |_| {
|
||||
if is_authenticated.0.get() {
|
||||
let navigate = use_navigate();
|
||||
navigate("/", Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
view! { <Setup /> }
|
||||
} />
|
||||
|
||||
<Route path="/*" view=move || {
|
||||
view! {
|
||||
<Show when=move || !is_loading.get() fallback=|| view! {
|
||||
<div class="flex items-center justify-center h-screen bg-base-100">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
}>
|
||||
<Show when=move || is_authenticated.get() fallback=|| view! { <Login /> }>
|
||||
// Protected Layout
|
||||
<div class="drawer lg:drawer-open h-full w-full">
|
||||
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<div class="drawer-content flex flex-col h-full overflow-hidden bg-base-100 text-base-content text-sm select-none">
|
||||
<Toolbar />
|
||||
|
||||
<main class="flex-1 flex flex-col min-w-0 bg-base-100 overflow-hidden pb-8">
|
||||
<Routes>
|
||||
<Route path="/" view=move || view! { <TorrentTable /> } />
|
||||
<Route path="/settings" view=move || view! { <div class="p-4">"Settings Page (Coming Soon)"</div> } />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
<StatusBar />
|
||||
</div>
|
||||
|
||||
<div class="drawer-side z-40 transition-none duration-0">
|
||||
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay transition-none duration-0"></label>
|
||||
<div class="menu p-0 min-h-full bg-base-200 text-base-content border-r border-base-300 transition-none duration-0">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<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=|| {
|
||||
// Standard 1: Always show Dashboard Skeleton
|
||||
view! {
|
||||
<div class="flex h-screen bg-background text-foreground overflow-hidden">
|
||||
// Sidebar skeleton
|
||||
<div class="w-56 border-r border-border p-4 space-y-4">
|
||||
<Skeleton class="h-8 w-3/4" />
|
||||
<div class="space-y-2">
|
||||
<Skeleton class="h-6 w-full" />
|
||||
<Skeleton class="h-6 w-full" />
|
||||
<Skeleton class="h-6 w-4/5" />
|
||||
<Skeleton class="h-6 w-full" />
|
||||
<Skeleton class="h-6 w-3/5" />
|
||||
<Skeleton class="h-6 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
// Main content skeleton
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<div class="border-b border-border p-4 flex items-center gap-4">
|
||||
<Skeleton class="h-8 w-48" />
|
||||
<Skeleton class="h-8 w-64" />
|
||||
<div class="ml-auto"><Skeleton class="h-8 w-24" /></div>
|
||||
</div>
|
||||
<div class="flex-1 p-4 space-y-3">
|
||||
<Skeleton class="h-10 w-full" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
<Skeleton class="h-10 w-3/4" />
|
||||
</div>
|
||||
<div class="border-t border-border p-3">
|
||||
<Skeleton class="h-5 w-96" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
}>
|
||||
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
||||
<Protected>
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<TorrentTable />
|
||||
</div>
|
||||
</div>
|
||||
</Protected>
|
||||
</Show>
|
||||
}
|
||||
}/>
|
||||
</Routes>
|
||||
</Router>
|
||||
</Show>
|
||||
}.into_any()
|
||||
}/>
|
||||
|
||||
<ToastContainer />
|
||||
<Route path=leptos_router::path!("/settings") view=move || {
|
||||
let authenticated = is_authenticated.0.get();
|
||||
Effect::new(move |_| {
|
||||
if !authenticated {
|
||||
let navigate = use_navigate();
|
||||
navigate("/login", Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<Show when=move || !is_loading.0.get() fallback=|| ()>
|
||||
<Show when=move || authenticated fallback=|| ()>
|
||||
<Protected>
|
||||
<div class="p-4">"Settings Page (Coming Soon)"</div>
|
||||
</Protected>
|
||||
</Show>
|
||||
</Show>
|
||||
}
|
||||
}/>
|
||||
</Routes>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,110 +1,95 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
||||
use crate::components::ui::input::{Input, InputType};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LoginRequest {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
use crate::components::ui::button::Button;
|
||||
|
||||
#[component]
|
||||
pub fn Login() -> impl IntoView {
|
||||
let (username, set_username) = create_signal(String::new());
|
||||
let (password, set_password) = create_signal(String::new());
|
||||
let (error, set_error) = create_signal(Option::<String>::None);
|
||||
let (loading, set_loading) = create_signal(false);
|
||||
let username = RwSignal::new(String::new());
|
||||
let password = RwSignal::new(String::new());
|
||||
let error = signal(Option::<String>::None);
|
||||
let loading = signal(false);
|
||||
|
||||
let handle_login = move |ev: web_sys::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_loading.set(true);
|
||||
set_error.set(None);
|
||||
loading.1.set(true);
|
||||
error.1.set(None);
|
||||
|
||||
let user = username.get();
|
||||
let pass = password.get();
|
||||
|
||||
spawn_local(async move {
|
||||
let req = LoginRequest {
|
||||
username: username.get(),
|
||||
password: password.get(),
|
||||
};
|
||||
|
||||
let client = gloo_net::http::Request::post("/api/auth/login")
|
||||
.json(&req)
|
||||
.expect("Failed to create request");
|
||||
|
||||
match client.send().await {
|
||||
Ok(resp) => {
|
||||
if resp.ok() {
|
||||
// Redirect to home on success
|
||||
let navigate = use_navigate();
|
||||
navigate("/", Default::default());
|
||||
} else {
|
||||
set_error.set(Some("Kullanıcı adı veya şifre hatalı".to_string()));
|
||||
}
|
||||
match shared::server_fns::auth::login(user, pass).await {
|
||||
Ok(_) => {
|
||||
let window = web_sys::window().expect("window should exist");
|
||||
let _ = window.location().set_href("/");
|
||||
}
|
||||
Err(_) => {
|
||||
set_error.set(Some("Bağlantı hatası".to_string()));
|
||||
error.1.set(Some("Geçersiz kullanıcı adı veya şifre".to_string()));
|
||||
loading.1.set(false);
|
||||
}
|
||||
}
|
||||
set_loading.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="flex items-center justify-center min-h-screen bg-base-200">
|
||||
<div class="card w-full max-w-sm shadow-xl bg-base-100">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title justify-center mb-4">"VibeTorrent Giriş"</h2>
|
||||
|
||||
<form on:submit=handle_login>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">"Kullanıcı Adı"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4">
|
||||
<Card class="w-full max-w-sm shadow-lg">
|
||||
<CardHeader class="pb-2 items-center">
|
||||
<div class="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-sm mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.25 0 003 2.48z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold tracking-tight text-2xl">"VibeTorrent"</h3>
|
||||
<p class="text-sm text-muted-foreground">"Hesabınıza giriş yapın"</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="pt-4">
|
||||
<form on:submit=handle_login class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium leading-none">"Kullanıcı Adı"</label>
|
||||
<Input
|
||||
r#type=InputType::Text
|
||||
placeholder="Kullanıcı adınız"
|
||||
class="input input-bordered w-full"
|
||||
prop:value=username
|
||||
on:input=move |ev| set_username.set(event_target_value(&ev))
|
||||
disabled=move || loading.get()
|
||||
bind_value=username
|
||||
disabled=loading.0.get()
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full mt-4">
|
||||
<label class="label">
|
||||
<span class="label-text">"Şifre"</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium leading-none">"Şifre"</label>
|
||||
<Input
|
||||
r#type=InputType::Password
|
||||
placeholder="******"
|
||||
class="input input-bordered w-full"
|
||||
prop:value=password
|
||||
on:input=move |ev| set_password.set(event_target_value(&ev))
|
||||
disabled=move || loading.get()
|
||||
bind_value=password
|
||||
disabled=loading.0.get()
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when=move || error.get().is_some()>
|
||||
<div class="alert alert-error mt-4 text-sm py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>{move || error.get()}</span>
|
||||
<Show when=move || error.0.get().is_some()>
|
||||
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{move || error.0.get().unwrap_or_default()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
type="submit"
|
||||
disabled=move || loading.get()
|
||||
<div class="pt-2">
|
||||
<Button
|
||||
class="w-full"
|
||||
attr:r#type="submit"
|
||||
attr:disabled=move || loading.0.get()
|
||||
>
|
||||
<Show when=move || loading.get() fallback=|| "Giriş Yap">
|
||||
<span class="loading loading-spinner"></span>
|
||||
<Show when=move || loading.0.get() fallback=|| view! { "Giriş Yap" }.into_any()>
|
||||
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
||||
"Giriş Yapılıyor..."
|
||||
</Show>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +1,119 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
||||
use crate::components::ui::input::{Input, InputType};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SetupRequest {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
use crate::components::ui::button::Button;
|
||||
|
||||
#[component]
|
||||
pub fn Setup() -> impl IntoView {
|
||||
let (username, set_username) = create_signal(String::new());
|
||||
let (password, set_password) = create_signal(String::new());
|
||||
let (confirm_password, set_confirm_password) = create_signal(String::new());
|
||||
let (error, set_error) = create_signal(Option::<String>::None);
|
||||
let (loading, set_loading) = create_signal(false);
|
||||
let username = RwSignal::new(String::new());
|
||||
let password = RwSignal::new(String::new());
|
||||
let confirm_password = RwSignal::new(String::new());
|
||||
let error = signal(Option::<String>::None);
|
||||
let loading = signal(false);
|
||||
|
||||
let handle_setup = move |ev: web_sys::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_loading.set(true);
|
||||
set_error.set(None);
|
||||
|
||||
|
||||
let pass = password.get();
|
||||
let confirm = confirm_password.get();
|
||||
|
||||
|
||||
if pass != confirm {
|
||||
set_error.set(Some("Şifreler eşleşmiyor".to_string()));
|
||||
set_loading.set(false);
|
||||
error.1.set(Some("Şifreler eşleşmiyor".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
if pass.len() < 6 {
|
||||
set_error.set(Some("Şifre en az 6 karakter olmalıdır".to_string()));
|
||||
set_loading.set(false);
|
||||
error.1.set(Some("Şifre en az 6 karakter olmalıdır".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
loading.1.set(true);
|
||||
error.1.set(None);
|
||||
|
||||
let user = username.get();
|
||||
|
||||
spawn_local(async move {
|
||||
let req = SetupRequest {
|
||||
username: username.get(),
|
||||
password: pass,
|
||||
};
|
||||
|
||||
let client = gloo_net::http::Request::post("/api/setup")
|
||||
.json(&req)
|
||||
.expect("Failed to create request");
|
||||
|
||||
match client.send().await {
|
||||
Ok(resp) => {
|
||||
if resp.ok() {
|
||||
// Redirect to login after setup
|
||||
let navigate = use_navigate();
|
||||
navigate("/login", Default::default());
|
||||
} else {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
set_error.set(Some(format!("Hata: {}", text)));
|
||||
}
|
||||
match shared::server_fns::auth::setup(user, pass).await {
|
||||
Ok(_) => {
|
||||
log::info!("Setup completed successfully, redirecting...");
|
||||
let window = web_sys::window().expect("window should exist");
|
||||
let _ = window.location().set_href("/");
|
||||
}
|
||||
Err(_) => {
|
||||
set_error.set(Some("Bağlantı hatası".to_string()));
|
||||
Err(e) => {
|
||||
log::error!("Setup failed: {:?}", e);
|
||||
error.1.set(Some("Kurulum sırasında bir hata oluştu".to_string()));
|
||||
loading.1.set(false);
|
||||
}
|
||||
}
|
||||
set_loading.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="flex items-center justify-center min-h-screen bg-base-200">
|
||||
<div class="card w-full max-w-md shadow-xl bg-base-100">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title justify-center mb-2">"VibeTorrent Kurulumu"</h2>
|
||||
<p class="text-center text-sm opacity-70 mb-4">"Yönetici hesabınızı oluşturun"</p>
|
||||
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4">
|
||||
<Card class="w-full max-w-md shadow-lg overflow-hidden">
|
||||
<CardHeader class="pb-2 items-center text-center">
|
||||
<div class="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-sm mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-3.497a2.548 2.548 0 113.586 3.586l-6.837 5.63m-5.108 3.497l2.496-3.03c.317-.384.74-.626 1.208-.766M15.75 9.25a2.548 2.548 0 11-5.096 0 2.548 2.548 0 015.096 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold tracking-tight text-2xl">"VibeTorrent Kurulumu"</h3>
|
||||
<p class="text-sm text-muted-foreground">"Yönetici hesabınızı oluşturun"</p>
|
||||
</CardHeader>
|
||||
|
||||
<form on:submit=handle_setup>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">"Kullanıcı Adı"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<CardContent class="pt-4">
|
||||
<form on:submit=handle_setup class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium leading-none">"Yönetici Kullanıcı Adı"</label>
|
||||
<Input
|
||||
r#type=InputType::Text
|
||||
placeholder="admin"
|
||||
class="input input-bordered w-full"
|
||||
prop:value=username
|
||||
on:input=move |ev| set_username.set(event_target_value(&ev))
|
||||
disabled=move || loading.get()
|
||||
required
|
||||
bind_value=username
|
||||
disabled=loading.0.get()
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full mt-4">
|
||||
<label class="label">
|
||||
<span class="label-text">"Şifre"</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium leading-none">"Şifre"</label>
|
||||
<Input
|
||||
r#type=InputType::Password
|
||||
placeholder="******"
|
||||
class="input input-bordered w-full"
|
||||
prop:value=password
|
||||
on:input=move |ev| set_password.set(event_target_value(&ev))
|
||||
disabled=move || loading.get()
|
||||
required
|
||||
bind_value=password
|
||||
disabled=loading.0.get()
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full mt-4">
|
||||
<label class="label">
|
||||
<span class="label-text">"Şifre Tekrar"</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium leading-none">"Şifre Onay"</label>
|
||||
<Input
|
||||
r#type=InputType::Password
|
||||
placeholder="******"
|
||||
class="input input-bordered w-full"
|
||||
prop:value=confirm_password
|
||||
on:input=move |ev| set_confirm_password.set(event_target_value(&ev))
|
||||
disabled=move || loading.get()
|
||||
required
|
||||
bind_value=confirm_password
|
||||
disabled=loading.0.get()
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when=move || error.get().is_some()>
|
||||
<div class="alert alert-error mt-4 text-sm py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>{move || error.get()}</span>
|
||||
<Show when=move || error.0.get().is_some() fallback=|| ()>
|
||||
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<span>{move || error.0.get().unwrap_or_default()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
type="submit"
|
||||
disabled=move || loading.get()
|
||||
<div class="pt-2">
|
||||
<Button
|
||||
class="w-full"
|
||||
attr:r#type="submit"
|
||||
attr:disabled=move || loading.0.get()
|
||||
>
|
||||
<Show when=move || loading.get() fallback=|| "Kurulumu Tamamla">
|
||||
<span class="loading loading-spinner"></span>
|
||||
"İşleniyor..."
|
||||
<Show when=move || loading.0.get() fallback=|| view! { "Kurulumu Tamamla" }.into_any()>
|
||||
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
||||
"Kuruluyor..."
|
||||
</Show>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +1,71 @@
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
use crate::components::ui::context_menu::{
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
|
||||
};
|
||||
use crate::components::ui::button_action::ButtonAction;
|
||||
use crate::components::ui::button::ButtonVariant;
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenu(
|
||||
position: (i32, i32),
|
||||
visible: bool,
|
||||
pub fn TorrentContextMenu(
|
||||
children: Children,
|
||||
torrent_hash: String,
|
||||
on_close: Callback<()>,
|
||||
on_action: Callback<(String, String)>, // (Action, Hash)
|
||||
on_action: Callback<(String, String)>,
|
||||
) -> impl IntoView {
|
||||
let handle_action = move |action: &str| {
|
||||
let hash = torrent_hash.clone();
|
||||
let action_str = action.to_string();
|
||||
|
||||
logging::log!("ContextMenu: Action '{}' for hash '{}'", action_str, hash);
|
||||
on_action.call((action_str, hash)); // Delegate FIRST
|
||||
on_close.call(()); // Close menu AFTER
|
||||
};
|
||||
|
||||
if !visible {
|
||||
return view! {}.into_view();
|
||||
}
|
||||
let hash_c1 = torrent_hash.clone();
|
||||
let hash_c2 = torrent_hash.clone();
|
||||
let hash_c3 = torrent_hash.clone();
|
||||
let hash_c4 = torrent_hash.clone();
|
||||
|
||||
let on_action_stored = StoredValue::new(on_action);
|
||||
|
||||
view! {
|
||||
// Backdrop to catch clicks outside
|
||||
<div
|
||||
class="fixed inset-0 z-[99] cursor-default"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
on:click=move |_| on_close.call(())
|
||||
on:contextmenu=move |e| e.prevent_default()
|
||||
></div>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
{children()}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent class="w-56 p-1.5">
|
||||
<ContextMenuItem on:click={let h = hash_c1; move |_| {
|
||||
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>
|
||||
|
||||
<div
|
||||
class="fixed z-[100] min-w-[200px] animate-in fade-in zoom-in-95 duration-100"
|
||||
style=format!("left: {}px; top: {}px", position.0, position.1)
|
||||
on:contextmenu=move |e| e.prevent_default()
|
||||
>
|
||||
<ul class="menu bg-base-200 text-base-content rounded-box shadow-xl border border-white/5 p-2 gap-1">
|
||||
|
||||
|
||||
<li>
|
||||
<button
|
||||
class="gap-3 active:bg-primary active:text-primary-content"
|
||||
on:click={
|
||||
let handle_action = handle_action.clone();
|
||||
move |_| handle_action("start")
|
||||
}
|
||||
<ButtonAction
|
||||
variant=ButtonVariant::Destructive
|
||||
class="w-full justify-start h-8 px-2 text-xs font-bold"
|
||||
hold_duration=1200
|
||||
on_action={let h = hash_c4; move || {
|
||||
on_action_stored.get_value().run(("delete_with_data".to_string(), h.clone()));
|
||||
crate::components::ui::context_menu::close_context_menu();
|
||||
}}
|
||||
>
|
||||
<svg class="w-4 h-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
"Resume"
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<button
|
||||
class="gap-3 active:bg-primary active:text-primary-content"
|
||||
on:click={
|
||||
let handle_action = handle_action.clone();
|
||||
move |_| handle_action("stop")
|
||||
}
|
||||
>
|
||||
<svg class="w-4 h-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
"Pause"
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<div class="divider my-0 h-px p-0 opacity-10"></div>
|
||||
|
||||
<li>
|
||||
<button
|
||||
class="gap-3 text-error hover:bg-error/10 active:bg-error active:text-error-content"
|
||||
on:click={
|
||||
let handle_action = handle_action.clone();
|
||||
move |_| handle_action("delete")
|
||||
}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
"Delete"
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<button
|
||||
class="gap-3 text-error hover:bg-error/10 active:bg-error active:text-error-content text-xs"
|
||||
on:click={
|
||||
let handle_action = handle_action.clone();
|
||||
move |_| handle_action("delete_with_data")
|
||||
}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
<span>"Delete with Data"</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}.into_view()
|
||||
"Verilerle Sil (Basılı Tut)"
|
||||
</ButtonAction>
|
||||
</div>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
}
|
||||
}
|
||||
|
||||
91
frontend/src/components/demos/demo_shimmer.rs
Normal file
91
frontend/src/components/demos/demo_shimmer.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::components::ui::button::{Button, ButtonVariant};
|
||||
use crate::components::ui::card::{Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle};
|
||||
use crate::components::ui::shimmer::Shimmer;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CardData {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Simulates a database fetch with 1 second delay
|
||||
#[server]
|
||||
pub async fn fetch_card_data() -> Result<CardData, ServerFnError> {
|
||||
// Simulate network/database latency (only on server)
|
||||
#[cfg(feature = "ssr")]
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
||||
Ok(CardData {
|
||||
title: "Fetched Title".to_string(),
|
||||
description: "This content was fetched from the server after a 1 second simulated delay. The shimmer effect automatically showed during the loading period.".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DemoShimmer() -> impl IntoView {
|
||||
// Loading state
|
||||
let loading = RwSignal::new(false);
|
||||
|
||||
// Store fetched data
|
||||
let card_data = RwSignal::new(None::<CardData>);
|
||||
|
||||
// Fetch handler using spawn_local for reliable repeated calls
|
||||
let on_fetch = move |_| {
|
||||
spawn_local(async move {
|
||||
loading.set(true);
|
||||
let result = fetch_card_data().await;
|
||||
if let Ok(data) = result {
|
||||
card_data.set(Some(data));
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-2">
|
||||
<Button variant=ButtonVariant::Outline on:click=move |_| loading.set(!loading.get())>
|
||||
"Toggle Loading"
|
||||
</Button>
|
||||
<Button variant=ButtonVariant::Default on:click=on_fetch>
|
||||
"Fetch Data (1s)"
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Shimmer loading=Signal::from(loading)>
|
||||
<Card class="max-w-lg lg:max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{move || {
|
||||
card_data.get().map(|data| data.title).unwrap_or_else(|| "Card Title".to_string())
|
||||
}}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<CardDescription>
|
||||
{move || {
|
||||
card_data
|
||||
.get()
|
||||
.map(|data| data.description)
|
||||
.unwrap_or_else(|| {
|
||||
"Click 'Toggle Loading' for manual control, or 'Fetch Data' to simulate a real server call with 1 second delay."
|
||||
.to_string()
|
||||
})
|
||||
}}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter class="justify-end">
|
||||
<Button variant=ButtonVariant::Outline>"Cancel"</Button>
|
||||
<Button>"Confirm"</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Shimmer>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
1
frontend/src/components/demos/mod.rs
Normal file
1
frontend/src/components/demos/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod demo_shimmer;
|
||||
3
frontend/src/components/hooks/mod.rs
Normal file
3
frontend/src/components/hooks/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod use_random;
|
||||
pub mod use_theme_mode;
|
||||
pub mod use_can_scroll_vertical;
|
||||
25
frontend/src/components/hooks/use_can_scroll_vertical.rs
Normal file
25
frontend/src/components/hooks/use_can_scroll_vertical.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
/// Hook to determine if an element can scroll vertically.
|
||||
///
|
||||
/// Returns (on_scroll_callback, can_scroll_up_signal, can_scroll_down_signal)
|
||||
pub fn use_can_scroll_vertical() -> (Callback<web_sys::Event>, ReadSignal<bool>, ReadSignal<bool>) {
|
||||
let can_scroll_up = RwSignal::new(false);
|
||||
let can_scroll_down = RwSignal::new(false);
|
||||
|
||||
let on_scroll = Callback::new(move |ev: web_sys::Event| {
|
||||
if let Some(target) = ev.target() {
|
||||
if let Some(el) = target.dyn_ref::<web_sys::HtmlElement>() {
|
||||
let scroll_top = el.scroll_top();
|
||||
let scroll_height = el.scroll_height();
|
||||
let client_height = el.client_height();
|
||||
|
||||
can_scroll_up.set(scroll_top > 0);
|
||||
can_scroll_down.set(scroll_top + client_height < scroll_height - 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(on_scroll, can_scroll_up.read_only(), can_scroll_down.read_only())
|
||||
}
|
||||
31
frontend/src/components/hooks/use_random.rs
Normal file
31
frontend/src/components/hooks/use_random.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
const PREFIX: &str = "rust_ui"; // Must NOT contain "/" or "-"
|
||||
|
||||
pub fn use_random_id() -> String {
|
||||
format!("_{PREFIX}_{}", generate_hash())
|
||||
}
|
||||
|
||||
pub fn use_random_id_for(element: &str) -> String {
|
||||
format!("{}_{PREFIX}_{}", element, generate_hash())
|
||||
}
|
||||
|
||||
pub fn use_random_transition_name() -> String {
|
||||
let random_id = use_random_id();
|
||||
format!("view-transition-name: {random_id}")
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(1);
|
||||
|
||||
fn generate_hash() -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let counter = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
counter.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
108
frontend/src/components/hooks/use_theme_mode.rs
Normal file
108
frontend/src/components/hooks/use_theme_mode.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use leptos::prelude::*;
|
||||
use web_sys::Storage;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ThemeMode {
|
||||
state: RwSignal<bool>,
|
||||
}
|
||||
|
||||
const LOCALSTORAGE_KEY: &str = "darkmode";
|
||||
|
||||
/// Hook to access the dark mode context
|
||||
///
|
||||
/// Returns the ThemeMode instance from context for easy access
|
||||
pub fn use_theme_mode() -> ThemeMode {
|
||||
expect_context::<ThemeMode>()
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
impl ThemeMode {
|
||||
#[must_use]
|
||||
/// Initializes a new ThemeMode instance.
|
||||
pub fn init() -> Self {
|
||||
let theme_mode = Self { state: RwSignal::new(false) };
|
||||
|
||||
provide_context(theme_mode);
|
||||
|
||||
// Use Effect to handle browser-only initialization
|
||||
Effect::new(move |_| {
|
||||
let initial = Self::get_storage_state().unwrap_or(Self::prefers_dark_mode());
|
||||
theme_mode.state.set(initial);
|
||||
});
|
||||
|
||||
theme_mode
|
||||
}
|
||||
|
||||
pub fn toggle(&self) {
|
||||
self.state.update(|state| {
|
||||
*state = !*state;
|
||||
Self::set_storage_state(*state);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_dark(&self) {
|
||||
self.set(true);
|
||||
}
|
||||
|
||||
pub fn set_light(&self) {
|
||||
self.set(false);
|
||||
}
|
||||
|
||||
/// - `dark`: Set to `true` for dark mode, and `false` for light mode.
|
||||
pub fn set(&self, dark: bool) {
|
||||
self.state.set(dark);
|
||||
Self::set_storage_state(dark);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self) -> bool {
|
||||
self.state.get()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_dark(&self) -> bool {
|
||||
self.state.get()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_light(&self) -> bool {
|
||||
!self.state.get()
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
/// Retrieves the local storage object, if available.
|
||||
fn get_storage() -> Option<Storage> {
|
||||
window().local_storage().ok().flatten()
|
||||
}
|
||||
|
||||
/// Retrieves the dark mode state from local storage, if available.
|
||||
fn get_storage_state() -> Option<bool> {
|
||||
Self::get_storage()
|
||||
.and_then(|storage| storage.get(LOCALSTORAGE_KEY).ok())
|
||||
.flatten()
|
||||
.and_then(|entry| entry.parse::<bool>().ok())
|
||||
}
|
||||
|
||||
/// Checks whether the user's system prefers dark mode based on media queries.
|
||||
fn prefers_dark_mode() -> bool {
|
||||
window()
|
||||
.match_media("(prefers-color-scheme: dark)")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|media| media.matches())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Stores the dark mode state in local storage.
|
||||
fn set_storage_state(state: bool) {
|
||||
if let Some(storage) = Self::get_storage() {
|
||||
storage.set(LOCALSTORAGE_KEY, state.to_string().as_str()).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
18
frontend/src/components/layout/footer.rs
Normal file
18
frontend/src/components/layout/footer.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
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,3 +1,4 @@
|
||||
pub mod sidebar;
|
||||
pub mod toolbar;
|
||||
pub mod statusbar;
|
||||
pub mod footer;
|
||||
pub mod protected;
|
||||
|
||||
31
frontend/src/components/layout/protected.rs
Normal file
31
frontend/src/components/layout/protected.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::components::layout::sidebar::Sidebar;
|
||||
use crate::components::layout::toolbar::Toolbar;
|
||||
use crate::components::layout::footer::Footer;
|
||||
use crate::components::ui::sidenav::{SidenavWrapper, Sidenav, SidenavInset};
|
||||
|
||||
#[component]
|
||||
pub fn Protected(children: Children) -> impl IntoView {
|
||||
view! {
|
||||
<SidenavWrapper attr:style="--sidenav-width:16rem; --sidenav-width-icon:3rem;">
|
||||
// Masaüstü Sidenav
|
||||
<Sidenav>
|
||||
<Sidebar />
|
||||
</Sidenav>
|
||||
|
||||
// İçerik Alanı
|
||||
<SidenavInset class="flex flex-col h-screen overflow-hidden">
|
||||
// Toolbar (Üst Bar)
|
||||
<Toolbar />
|
||||
|
||||
// Ana İçerik
|
||||
<main class="flex-1 overflow-y-auto relative bg-background flex flex-col">
|
||||
<div class="flex-1">
|
||||
{children()}
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
</SidenavInset>
|
||||
</SidenavWrapper>
|
||||
}
|
||||
}
|
||||
@@ -1,142 +1,225 @@
|
||||
use leptos::wasm_bindgen::JsCast;
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use crate::components::ui::sidenav::*;
|
||||
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
|
||||
use crate::components::ui::theme_toggle::ThemeToggle;
|
||||
use crate::components::ui::switch::Switch;
|
||||
|
||||
#[component]
|
||||
pub fn Sidebar() -> impl IntoView {
|
||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||
|
||||
let total_count = move || store.torrents.get().len();
|
||||
let total_count = move || store.torrents.with(|map| map.len());
|
||||
let downloading_count = move || {
|
||||
store
|
||||
.torrents
|
||||
.get()
|
||||
.iter()
|
||||
.filter(|t| t.status == shared::TorrentStatus::Downloading)
|
||||
.count()
|
||||
store.torrents.with(|map| {
|
||||
map.values()
|
||||
.filter(|t| t.status == shared::TorrentStatus::Downloading)
|
||||
.count()
|
||||
})
|
||||
};
|
||||
let seeding_count = move || {
|
||||
store
|
||||
.torrents
|
||||
.get()
|
||||
.iter()
|
||||
.filter(|t| t.status == shared::TorrentStatus::Seeding)
|
||||
.count()
|
||||
store.torrents.with(|map| {
|
||||
map.values()
|
||||
.filter(|t| t.status == shared::TorrentStatus::Seeding)
|
||||
.count()
|
||||
})
|
||||
};
|
||||
let completed_count = move || {
|
||||
store
|
||||
.torrents
|
||||
.get()
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t.status == shared::TorrentStatus::Seeding
|
||||
|| (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0)
|
||||
})
|
||||
.count()
|
||||
store.torrents.with(|map| {
|
||||
map.values()
|
||||
.filter(|t| {
|
||||
t.status == shared::TorrentStatus::Seeding
|
||||
|| (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0)
|
||||
})
|
||||
.count()
|
||||
})
|
||||
};
|
||||
let paused_count = move || {
|
||||
store
|
||||
.torrents
|
||||
.get()
|
||||
.iter()
|
||||
.filter(|t| t.status == shared::TorrentStatus::Paused)
|
||||
.count()
|
||||
store.torrents.with(|map| {
|
||||
map.values()
|
||||
.filter(|t| t.status == shared::TorrentStatus::Paused)
|
||||
.count()
|
||||
})
|
||||
};
|
||||
let inactive_count = move || {
|
||||
store
|
||||
.torrents
|
||||
.get()
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t.status == shared::TorrentStatus::Paused
|
||||
|| t.status == shared::TorrentStatus::Error
|
||||
})
|
||||
.count()
|
||||
};
|
||||
|
||||
let close_drawer = move || {
|
||||
if let Some(element) = document().get_element_by_id("my-drawer") {
|
||||
if let Ok(input) = element.dyn_into::<web_sys::HtmlInputElement>() {
|
||||
input.set_checked(false);
|
||||
}
|
||||
}
|
||||
store.torrents.with(|map| {
|
||||
map.values()
|
||||
.filter(|t| {
|
||||
t.status == shared::TorrentStatus::Paused
|
||||
|| t.status == shared::TorrentStatus::Error
|
||||
})
|
||||
.count()
|
||||
})
|
||||
};
|
||||
|
||||
let set_filter = move |f: crate::store::FilterStatus| {
|
||||
store.filter.set(f);
|
||||
close_drawer();
|
||||
};
|
||||
|
||||
let filter_class = move |f: crate::store::FilterStatus| {
|
||||
if store.filter.get() == f {
|
||||
"active"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
let is_active = move |f: crate::store::FilterStatus| store.filter.get() == f;
|
||||
|
||||
let username = move || {
|
||||
store.user.get().unwrap_or_else(|| "User".to_string())
|
||||
};
|
||||
|
||||
let first_letter = move || {
|
||||
username().chars().next().unwrap_or('?').to_uppercase().to_string()
|
||||
};
|
||||
|
||||
let on_push_toggle = move |checked: bool| {
|
||||
spawn_local(async move {
|
||||
if checked {
|
||||
crate::store::subscribe_to_push_notifications().await;
|
||||
} else {
|
||||
crate::store::unsubscribe_from_push_notifications().await;
|
||||
}
|
||||
if let Ok(enabled) = crate::store::is_push_subscribed().await {
|
||||
store.push_enabled.set(enabled);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="w-64 h-full flex flex-col bg-base-200 border-r border-base-300" style="padding-top: env(safe-area-inset-top);">
|
||||
<div class="p-2">
|
||||
<ul class="menu w-full rounded-box gap-1">
|
||||
<li class="menu-title text-primary uppercase font-bold px-4">"Filters"</li>
|
||||
<li>
|
||||
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::All))} on:click=move |_| set_filter(crate::store::FilterStatus::All)>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
"All"
|
||||
<span class="badge badge-sm badge-ghost ml-auto">{total_count}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Downloading))} on:click=move |_| set_filter(crate::store::FilterStatus::Downloading)>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
"Downloading"
|
||||
<span class="badge badge-sm badge-ghost ml-auto">{downloading_count}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Seeding))} on:click=move |_| set_filter(crate::store::FilterStatus::Seeding)>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
"Seeding"
|
||||
<span class="badge badge-sm badge-ghost ml-auto">{seeding_count}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Completed))} on:click=move |_| set_filter(crate::store::FilterStatus::Completed)>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
"Completed"
|
||||
<span class="badge badge-sm badge-ghost ml-auto">{completed_count}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Paused))} on:click=move |_| set_filter(crate::store::FilterStatus::Paused)>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||
</svg>
|
||||
"Paused"
|
||||
<span class="badge badge-sm badge-ghost ml-auto">{paused_count}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Inactive))} on:click=move |_| set_filter(crate::store::FilterStatus::Inactive)>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
"Inactive"
|
||||
<span class="badge badge-sm badge-ghost ml-auto">{inactive_count}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<SidenavHeader>
|
||||
<div class="flex items-center gap-2 px-2 py-4">
|
||||
<div class="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<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 class="grid flex-1 text-left text-sm leading-tight overflow-hidden">
|
||||
<span class="truncate font-semibold text-foreground text-base">"VibeTorrent"</span>
|
||||
<span class="truncate text-[10px] text-muted-foreground opacity-70">"v3.0.0"</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidenavHeader>
|
||||
|
||||
<SidenavContent>
|
||||
<SidenavGroup>
|
||||
<SidenavGroupLabel>"Filtreler"</SidenavGroupLabel>
|
||||
<SidenavGroupContent>
|
||||
<SidenavMenu>
|
||||
<SidebarItem
|
||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::All))
|
||||
on_click=move |_| set_filter(crate::store::FilterStatus::All)
|
||||
icon="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
label="Tümü"
|
||||
count=Signal::derive(total_count)
|
||||
/>
|
||||
<SidebarItem
|
||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Downloading))
|
||||
on_click=move |_| set_filter(crate::store::FilterStatus::Downloading)
|
||||
icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
label="İndirilenler"
|
||||
count=Signal::derive(downloading_count)
|
||||
/>
|
||||
<SidebarItem
|
||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Seeding))
|
||||
on_click=move |_| set_filter(crate::store::FilterStatus::Seeding)
|
||||
icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||
label="Gönderilenler"
|
||||
count=Signal::derive(seeding_count)
|
||||
/>
|
||||
<SidebarItem
|
||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Completed))
|
||||
on_click=move |_| set_filter(crate::store::FilterStatus::Completed)
|
||||
icon="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
label="Tamamlananlar"
|
||||
count=Signal::derive(completed_count)
|
||||
/>
|
||||
<SidebarItem
|
||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Paused))
|
||||
on_click=move |_| set_filter(crate::store::FilterStatus::Paused)
|
||||
icon="M15.75 5.25v13.5m-7.5-13.5v13.5"
|
||||
label="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>
|
||||
|
||||
</div>
|
||||
<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 class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
|
||||
<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">
|
||||
{first_letter}
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div>
|
||||
<div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
|
||||
<Button
|
||||
variant=ButtonVariant::Ghost
|
||||
size=ButtonSize::Icon
|
||||
class="size-7 text-destructive hover:bg-destructive/10"
|
||||
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="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>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidenavFooter>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SidebarItem(
|
||||
active: Signal<bool>,
|
||||
on_click: impl Fn(web_sys::MouseEvent) + 'static + Send,
|
||||
#[prop(into)] icon: String,
|
||||
#[prop(into)] label: &'static str,
|
||||
count: Signal<usize>,
|
||||
) -> 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! {
|
||||
<SidenavMenuItem>
|
||||
<SidenavMenuButton
|
||||
variant=Signal::derive(variant)
|
||||
class=Signal::derive(class)
|
||||
on:click=on_click
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d=icon.clone() />
|
||||
</svg>
|
||||
<span class="flex-1 truncate">{label}</span>
|
||||
<span class="text-[10px] font-mono opacity-50">{count}</span>
|
||||
</SidenavMenuButton>
|
||||
</SidenavMenuItem>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
use leptos::*;
|
||||
use shared::GlobalLimitRequest;
|
||||
|
||||
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;
|
||||
|
||||
let initial_theme = if let Some(win) = web_sys::window() {
|
||||
if let Some(doc) = win.document() {
|
||||
doc.document_element()
|
||||
.and_then(|el| el.get_attribute("data-theme"))
|
||||
.unwrap_or_else(|| "dark".to_string())
|
||||
} else {
|
||||
"dark".to_string()
|
||||
}
|
||||
} else {
|
||||
"dark".to_string()
|
||||
};
|
||||
|
||||
let (current_theme, set_current_theme) = create_signal(initial_theme);
|
||||
|
||||
create_effect(move |_| {
|
||||
if let Some(win) = web_sys::window() {
|
||||
if let Some(storage) = win.local_storage().ok().flatten() {
|
||||
if let Ok(Some(stored_theme)) = storage.get_item("vibetorrent_theme") {
|
||||
let theme = stored_theme.to_lowercase();
|
||||
set_current_theme.set(theme.clone());
|
||||
if let Some(doc) = win.document() {
|
||||
let _ = doc
|
||||
.document_element()
|
||||
.unwrap()
|
||||
.set_attribute("data-theme", &theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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();
|
||||
logging::log!("Setting {} limit to {}", limit_type, val);
|
||||
|
||||
spawn_local(async move {
|
||||
let req_body = 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),
|
||||
}
|
||||
};
|
||||
|
||||
let client =
|
||||
gloo_net::http::Request::post("/api/settings/global-limits").json(&req_body);
|
||||
|
||||
match client {
|
||||
Ok(req) => match req.send().await {
|
||||
Ok(resp) => {
|
||||
if !resp.ok() {
|
||||
logging::error!(
|
||||
"Failed to set limit: {} {}",
|
||||
resp.status(),
|
||||
resp.status_text()
|
||||
);
|
||||
} else {
|
||||
logging::log!("Limit set successfully");
|
||||
}
|
||||
}
|
||||
Err(e) => logging::error!("Network error setting limit: {}", e),
|
||||
},
|
||||
Err(e) => logging::error!("Failed to create request: {}", e),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Signal-based dropdown state: 0=none, 1=download, 2=upload, 3=theme
|
||||
let (active_dropdown, set_active_dropdown) = create_signal(0u8);
|
||||
|
||||
// Toggle a specific dropdown
|
||||
let toggle = move |id: u8| {
|
||||
let current = active_dropdown.get_untracked();
|
||||
if current == id {
|
||||
set_active_dropdown.set(0);
|
||||
} else {
|
||||
set_active_dropdown.set(id);
|
||||
}
|
||||
};
|
||||
|
||||
// Close all dropdowns
|
||||
let close_all = move || {
|
||||
set_active_dropdown.set(0);
|
||||
};
|
||||
|
||||
view! {
|
||||
// Transparent overlay to close dropdowns when clicking outside
|
||||
<Show when=move || active_dropdown.get() != 0>
|
||||
<div
|
||||
class="fixed inset-0 z-[98] cursor-default"
|
||||
on:pointerdown=move |_| close_all()
|
||||
></div>
|
||||
</Show>
|
||||
|
||||
<div class="fixed bottom-0 left-0 right-0 h-8 min-h-8 bg-base-200 border-t border-base-300 flex items-center px-4 text-xs gap-4 text-base-content/70 z-[99]">
|
||||
|
||||
// --- DOWNLOAD SPEED DROPDOWN ---
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none"
|
||||
title="Global Download Speed - Click to Set Limit"
|
||||
on:pointerdown=move |e| {
|
||||
e.stop_propagation();
|
||||
toggle(1);
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="absolute bottom-full left-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300"
|
||||
style=move || if active_dropdown.get() == 1 { "display: block" } else { "display: none" }
|
||||
>
|
||||
{
|
||||
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 || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
|
||||
on:pointerdown=move |e| {
|
||||
e.stop_propagation();
|
||||
set_limit("down", val);
|
||||
close_all();
|
||||
}
|
||||
>
|
||||
{label}
|
||||
<Show when=is_active fallback=|| ()>
|
||||
<span>"✓"</span>
|
||||
</Show>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
// --- UPLOAD SPEED DROPDOWN ---
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none"
|
||||
title="Global Upload Speed - Click to Set Limit"
|
||||
on:pointerdown=move |e| {
|
||||
e.stop_propagation();
|
||||
toggle(2);
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="absolute bottom-full left-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300"
|
||||
style=move || if active_dropdown.get() == 2 { "display: block" } else { "display: none" }
|
||||
>
|
||||
{
|
||||
limits.clone().into_iter().map(|(val, label)| {
|
||||
let is_active = move || {
|
||||
let current = stats.get().up_limit.unwrap_or(0);
|
||||
(current - val).abs() < 1024
|
||||
};
|
||||
view! {
|
||||
<li>
|
||||
<button
|
||||
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
|
||||
on:pointerdown=move |e| {
|
||||
e.stop_propagation();
|
||||
set_limit("up", val);
|
||||
close_all();
|
||||
}
|
||||
>
|
||||
{label}
|
||||
<Show when=is_active fallback=|| ()>
|
||||
<span>"✓"</span>
|
||||
</Show>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
title="Change Theme"
|
||||
on:pointerdown=move |e| {
|
||||
e.stop_propagation();
|
||||
toggle(3);
|
||||
}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="absolute bottom-full right-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-52 mb-2 border border-base-300 max-h-96 overflow-y-auto"
|
||||
style=move || if active_dropdown.get() == 3 { "display: block" } else { "display: none" }
|
||||
>
|
||||
{
|
||||
let themes = vec![
|
||||
"light", "dark", "dim", "nord", "cupcake", "dracula", "cyberpunk", "emerald", "sunset", "abyss"
|
||||
];
|
||||
themes.into_iter().map(|theme| {
|
||||
view! {
|
||||
<li>
|
||||
<button
|
||||
class=move || if current_theme.get() == theme { "bg-primary/10 text-primary font-bold text-xs capitalize" } else { "text-xs capitalize" }
|
||||
on:pointerdown=move |e| {
|
||||
e.stop_propagation();
|
||||
set_current_theme.set(theme.to_string());
|
||||
if let Some(win) = web_sys::window() {
|
||||
if let Some(doc) = win.document() {
|
||||
let _ = doc.document_element().unwrap().set_attribute("data-theme", theme);
|
||||
}
|
||||
if let Some(storage) = win.local_storage().ok().flatten() {
|
||||
let _ = storage.set_item("vibetorrent_theme", theme);
|
||||
}
|
||||
}
|
||||
close_all();
|
||||
}
|
||||
>
|
||||
{theme}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
title="Settings & Notification Permissions"
|
||||
on:click=move |_| {
|
||||
// Request push notification permission when settings button is clicked
|
||||
spawn_local(async {
|
||||
log::info!("Settings button clicked - requesting push notification permission");
|
||||
|
||||
// Check current permission state before requesting
|
||||
let window = web_sys::window().expect("window should exist");
|
||||
let _current_perm = js_sys::Reflect::get(&window, &"Notification".into())
|
||||
.ok()
|
||||
.and_then(|n| js_sys::Reflect::get(&n, &"permission".into()).ok())
|
||||
.and_then(|p| p.as_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
crate::store::subscribe_to_push_notifications().await;
|
||||
|
||||
// Check permission after request
|
||||
let new_perm = js_sys::Reflect::get(&window, &"Notification".into())
|
||||
.ok()
|
||||
.and_then(|n| js_sys::Reflect::get(&n, &"permission".into()).ok())
|
||||
.and_then(|p| p.as_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(store) = use_context::<crate::store::TorrentStore>() {
|
||||
if new_perm == "granted" {
|
||||
crate::store::show_toast_with_signal(
|
||||
store.notifications,
|
||||
shared::NotificationLevel::Success,
|
||||
"Bildirimler etkinleştirildi! Torrent tamamlandığında bildirim alacaksınız.".to_string(),
|
||||
);
|
||||
} else if new_perm == "denied" {
|
||||
crate::store::show_toast_with_signal(
|
||||
store.notifications,
|
||||
shared::NotificationLevel::Error,
|
||||
"Bildirim izni reddedildi. Tarayıcı ayarlarından izin verebilirsiniz.".to_string(),
|
||||
);
|
||||
} else {
|
||||
crate::store::show_toast_with_signal(
|
||||
store.notifications,
|
||||
shared::NotificationLevel::Warning,
|
||||
"Bildirim izni verilemedi. Açılan izin penceresinde 'İzin Ver' seçeneğini seçin.".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
>
|
||||
<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 01.26-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,64 +1,55 @@
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
use icons::{PanelLeft, Plus};
|
||||
use crate::components::torrent::add_torrent::AddTorrentDialogContent;
|
||||
use crate::components::ui::button::{ButtonVariant, ButtonSize};
|
||||
use crate::components::ui::sheet::{Sheet, SheetContent, SheetTrigger, SheetDirection};
|
||||
use crate::components::ui::dialog::{Dialog, DialogContent, DialogTrigger};
|
||||
use crate::components::layout::sidebar::Sidebar;
|
||||
|
||||
#[component]
|
||||
pub fn Toolbar() -> impl IntoView {
|
||||
let (show_add_modal, set_show_add_modal) = create_signal(false);
|
||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||
|
||||
view! {
|
||||
<div class="navbar min-h-14 h-auto bg-base-100 p-0" style="padding-top: env(safe-area-inset-top);">
|
||||
<div class="navbar-start gap-4 px-4">
|
||||
<label for="my-drawer" class="btn btn-square btn-ghost lg:hidden drawer-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-sm btn-primary gap-2 font-normal"
|
||||
title="Add Magnet Link"
|
||||
on:click=move |_| set_show_add_modal.set(true)
|
||||
>
|
||||
<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 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
"Add Torrent"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="navbar-end gap-2 px-4">
|
||||
<div class="join">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
class="input input-sm input-bordered join-item w-full max-w-xs focus:outline-none"
|
||||
prop:value=move || store.search_query.get()
|
||||
on:input=move |ev| store.search_query.set(event_target_value(&ev))
|
||||
on:keydown=move |ev: web_sys::KeyboardEvent| {
|
||||
if ev.key() == "Escape" {
|
||||
store.search_query.set(String::new());
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Show when=move || !store.search_query.get().is_empty()>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost join-item border-base-content/20 border-l-0 px-2"
|
||||
title="Clear Search"
|
||||
on:click=move |_| store.search_query.set(String::new())
|
||||
<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 (Mobil) + Add Torrent
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
// --- MOBILE SHEET (SIDEBAR) ---
|
||||
<div class="lg:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger variant=ButtonVariant::Ghost size=ButtonSize::Icon class="size-9">
|
||||
<PanelLeft class="size-5" />
|
||||
<span class="hidden">"Menüyü Aç"</span>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
direction=SheetDirection::Left
|
||||
class="p-0 w-[18rem] bg-card border-r border-border"
|
||||
hide_close_button=true
|
||||
>
|
||||
<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 opacity-70">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<Sidebar />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
variant=ButtonVariant::Default
|
||||
class="gap-2"
|
||||
>
|
||||
<Plus class="w-4 h-4 md:w-5 md:h-5" />
|
||||
<span class="hidden sm:inline">"Add Torrent"</span>
|
||||
<span class="sm:hidden">"Add"</span>
|
||||
</DialogTrigger>
|
||||
<DialogContent id="add-torrent-dialog" class="sm:max-w-[425px]">
|
||||
<AddTorrentDialogContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Show when=move || show_add_modal.get()>
|
||||
<crate::components::torrent::add_torrent::AddTorrentModal on_close=move |_| set_show_add_modal.set(false) />
|
||||
</Show>
|
||||
// Sağ kısım boş
|
||||
<div class="flex flex-1 items-center justify-end gap-2">
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod hooks;
|
||||
pub mod context_menu;
|
||||
pub mod layout;
|
||||
pub mod modal;
|
||||
pub mod toast;
|
||||
pub mod torrent;
|
||||
pub mod auth;
|
||||
// pub mod toast; (Removed)
|
||||
pub mod ui;
|
||||
pub mod demos;
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
use leptos::*;
|
||||
|
||||
#[component]
|
||||
pub fn Modal(
|
||||
#[prop(into)] title: String,
|
||||
children: Children,
|
||||
#[prop(into)] on_confirm: Callback<()>,
|
||||
#[prop(into)] on_cancel: Callback<()>,
|
||||
#[prop(into)] visible: Signal<bool>,
|
||||
#[prop(into, default = "Confirm".to_string())] confirm_text: String,
|
||||
#[prop(into, default = "Cancel".to_string())] cancel_text: String,
|
||||
#[prop(into, default = false)] is_danger: bool,
|
||||
) -> impl IntoView {
|
||||
let title = store_value(title);
|
||||
// Eagerly render children to a Fragment, which is Clone
|
||||
let child_view = store_value(children());
|
||||
let on_confirm = store_value(on_confirm);
|
||||
let on_cancel = store_value(on_cancel);
|
||||
let confirm_text = store_value(confirm_text);
|
||||
let cancel_text = store_value(cancel_text);
|
||||
|
||||
view! {
|
||||
<Show when=move || visible.get() fallback=|| ()>
|
||||
<div class="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-end md:items-center justify-center z-[200] animate-in fade-in duration-200 sm:p-4">
|
||||
<div class="bg-card p-6 rounded-t-2xl md:rounded-lg w-full max-w-sm shadow-xl border border-border ring-0 transform transition-all animate-in slide-in-from-bottom-10 md:slide-in-from-bottom-0 md:zoom-in-95">
|
||||
<h3 class="text-lg font-semibold text-card-foreground mb-4">{title.get_value()}</h3>
|
||||
|
||||
<div class="text-muted-foreground mb-6 text-sm">
|
||||
{child_view.with_value(|c| c.clone())}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<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 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
|
||||
on:click=move |_| on_cancel.with_value(|cb| cb.call(()))
|
||||
>
|
||||
{cancel_text.get_value()}
|
||||
</button>
|
||||
<button
|
||||
class=move || crate::utils::cn(format!("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 h-10 px-4 py-2 {}",
|
||||
if is_danger { "bg-destructive text-destructive-foreground hover:bg-destructive/90" }
|
||||
else { "bg-primary text-primary-foreground hover:bg-primary/90" }
|
||||
))
|
||||
on:click=move |_| {
|
||||
logging::log!("Modal: Confirm clicked");
|
||||
on_confirm.with_value(|cb| cb.call(()))
|
||||
}
|
||||
>
|
||||
{confirm_text.get_value()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
@@ -1,83 +1,111 @@
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
use shared::NotificationLevel;
|
||||
|
||||
// ============================================================================
|
||||
// Toast Components - DaisyUI Alert Style
|
||||
// ============================================================================
|
||||
|
||||
/// Returns the DaisyUI alert class for the notification level
|
||||
fn get_alert_class(level: &NotificationLevel) -> &'static str {
|
||||
match level {
|
||||
NotificationLevel::Info => "alert alert-info",
|
||||
NotificationLevel::Success => "alert alert-success",
|
||||
NotificationLevel::Warning => "alert alert-warning",
|
||||
NotificationLevel::Error => "alert alert-error",
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Toast {
|
||||
pub id: String,
|
||||
pub message: String,
|
||||
pub level: NotificationLevel,
|
||||
pub visible: RwSignal<bool>,
|
||||
}
|
||||
|
||||
/// Individual toast item component
|
||||
#[component]
|
||||
fn ToastItem(
|
||||
level: NotificationLevel,
|
||||
message: String,
|
||||
) -> impl IntoView {
|
||||
let alert_class = get_alert_class(&level);
|
||||
|
||||
// DaisyUI SVG icons
|
||||
let icon_svg = match level {
|
||||
NotificationLevel::Info => view! {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
}.into_view(),
|
||||
NotificationLevel::Success => view! {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
}.into_view(),
|
||||
NotificationLevel::Warning => view! {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
}.into_view(),
|
||||
NotificationLevel::Error => view! {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
}.into_view(),
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class=alert_class>
|
||||
{icon_svg}
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ToastContext {
|
||||
pub toasts: RwSignal<HashMap<String, Toast>>,
|
||||
}
|
||||
|
||||
/// Main toast container - renders all active notifications
|
||||
#[component]
|
||||
pub fn ToastContainer() -> impl IntoView {
|
||||
let store = use_context::<crate::store::TorrentStore>().expect("TorrentStore not provided");
|
||||
let notifications = store.notifications;
|
||||
impl ToastContext {
|
||||
pub fn add(&self, message: impl Into<String>, level: NotificationLevel) {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let message = message.into();
|
||||
let toast = Toast {
|
||||
id: id.clone(),
|
||||
message,
|
||||
level,
|
||||
visible: RwSignal::new(true),
|
||||
};
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="toast toast-end toast-bottom"
|
||||
style="position: fixed; bottom: 20px; right: 20px; z-index: 99999;"
|
||||
>
|
||||
<For
|
||||
each=move || notifications.get()
|
||||
key=|item| item.id
|
||||
children=move |item| {
|
||||
view! {
|
||||
<ToastItem
|
||||
level=item.notification.level
|
||||
message=item.notification.message
|
||||
/>
|
||||
}
|
||||
self.toasts.update(|m| {
|
||||
m.insert(id.clone(), toast);
|
||||
});
|
||||
|
||||
// Auto remove after 5 seconds
|
||||
let toasts = self.toasts;
|
||||
let id_clone = id.clone();
|
||||
leptos::task::spawn_local(async move {
|
||||
gloo_timers::future::TimeoutFuture::new(5000).await;
|
||||
toasts.update(|m| {
|
||||
if let Some(t) = m.get(&id_clone) {
|
||||
t.visible.set(false);
|
||||
}
|
||||
/>
|
||||
});
|
||||
// Wait for animation
|
||||
gloo_timers::future::TimeoutFuture::new(300).await;
|
||||
toasts.update(|m| {
|
||||
m.remove(&id_clone);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn provide_toast_context() {
|
||||
let toasts = RwSignal::new(HashMap::new());
|
||||
provide_context(ToastContext { toasts });
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Toaster() -> impl IntoView {
|
||||
let context = expect_context::<ToastContext>();
|
||||
|
||||
view! {
|
||||
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-sm pointer-events-none">
|
||||
{move || {
|
||||
context.toasts.get().into_values().map(|toast| {
|
||||
view! { <ToastItem toast=toast /> }
|
||||
}).collect::<Vec<_>>()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ToastItem(toast: Toast) -> impl IntoView {
|
||||
let (visible, set_visible) = (toast.visible, toast.visible.write_only());
|
||||
|
||||
let base_classes = "pointer-events-auto relative w-full rounded-lg border p-4 shadow-lg transition-all duration-300 ease-in-out";
|
||||
let color_classes = match toast.level {
|
||||
NotificationLevel::Success => "bg-green-50 text-green-900 border-green-200 dark:bg-green-900 dark:text-green-100 dark:border-green-800",
|
||||
NotificationLevel::Error => "bg-red-50 text-red-900 border-red-200 dark:bg-red-900 dark:text-red-100 dark:border-red-800",
|
||||
NotificationLevel::Warning => "bg-yellow-50 text-yellow-900 border-yellow-200 dark:bg-yellow-900 dark:text-yellow-100 dark:border-yellow-800",
|
||||
NotificationLevel::Info => "bg-blue-50 text-blue-900 border-blue-200 dark:bg-blue-900 dark:text-blue-100 dark:border-blue-800",
|
||||
};
|
||||
|
||||
view! {
|
||||
<div
|
||||
class=move || format!("{} {} {}",
|
||||
base_classes,
|
||||
color_classes,
|
||||
if visible.get() { "opacity-100 translate-x-0" } else { "opacity-0 translate-x-full" }
|
||||
)
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">{toast.message.clone()}</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex shrink-0 opacity-50 hover:opacity-100 focus:opacity-100 focus:outline-none"
|
||||
on:click=move |_| set_visible.set(false)
|
||||
>
|
||||
<span class="sr-only">"Kapat"</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
|
||||
<line x1="18" x2="6" y1="6" y2="18"></line>
|
||||
<line x1="6" x2="18" y1="6" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,128 +1,100 @@
|
||||
use leptos::*;
|
||||
use leptos::html::Dialog;
|
||||
use crate::store::{show_toast_with_signal, TorrentStore};
|
||||
use shared::NotificationLevel;
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use wasm_bindgen::JsCast;
|
||||
use crate::components::ui::input::{Input, InputType};
|
||||
use crate::api;
|
||||
use crate::components::ui::button::Button;
|
||||
use crate::components::ui::dialog::{
|
||||
DialogBody, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn AddTorrentModal(
|
||||
#[prop(into)]
|
||||
on_close: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
let store = use_context::<TorrentStore>().expect("TorrentStore not provided");
|
||||
let notifications = store.notifications;
|
||||
|
||||
let dialog_ref = create_node_ref::<Dialog>();
|
||||
let (uri, set_uri) = create_signal(String::new());
|
||||
let (is_loading, set_loading) = create_signal(false);
|
||||
let (error_msg, set_error_msg) = create_signal(Option::<String>::None);
|
||||
pub fn AddTorrentDialogContent() -> impl IntoView {
|
||||
let uri = RwSignal::new(String::new());
|
||||
let is_loading = signal(false);
|
||||
let error_msg = signal(Option::<String>::None);
|
||||
|
||||
// Effect to open the dialog when the component mounts/renders
|
||||
create_effect(move |_| {
|
||||
if let Some(dialog) = dialog_ref.get() {
|
||||
let _ = dialog.show_modal();
|
||||
}
|
||||
});
|
||||
|
||||
let handle_submit = move |_| {
|
||||
let handle_submit = move |ev: web_sys::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
let uri_val = uri.get();
|
||||
|
||||
if uri_val.is_empty() {
|
||||
show_toast_with_signal(notifications, NotificationLevel::Warning, "Lütfen bir Magnet URI veya URL girin");
|
||||
set_error_msg.set(Some("Please enter a Magnet URI or URL".to_string()));
|
||||
error_msg.1.set(Some("Lütfen bir Magnet URI veya URL girin".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
set_loading.set(true);
|
||||
set_error_msg.set(None);
|
||||
is_loading.1.set(true);
|
||||
error_msg.1.set(None);
|
||||
|
||||
spawn_local(async move {
|
||||
let req_body = serde_json::json!({
|
||||
"uri": uri_val
|
||||
});
|
||||
|
||||
match gloo_net::http::Request::post("/api/torrents/add")
|
||||
.json(&req_body)
|
||||
{
|
||||
Ok(req) => {
|
||||
match req.send().await {
|
||||
Ok(resp) => {
|
||||
if resp.ok() {
|
||||
logging::log!("Torrent added successfully");
|
||||
show_toast_with_signal(notifications, NotificationLevel::Success, "Torrent eklendi");
|
||||
set_loading.set(false);
|
||||
if let Some(dialog) = dialog_ref.get() {
|
||||
dialog.close();
|
||||
}
|
||||
on_close.call(());
|
||||
} else {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
logging::error!("Failed to add torrent: {} - {}", status, text);
|
||||
show_toast_with_signal(notifications, NotificationLevel::Error, "Torrent eklenemedi");
|
||||
set_error_msg.set(Some(format!("Error {}: {}", status, text)));
|
||||
set_loading.set(false);
|
||||
match api::torrent::add(&uri_val).await {
|
||||
Ok(_) => {
|
||||
log::info!("Torrent added successfully");
|
||||
crate::store::toast_success("Torrent başarıyla eklendi");
|
||||
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
logging::error!("Network error: {}", e);
|
||||
show_toast_with_signal(notifications, NotificationLevel::Error, "Bağlantı hatası");
|
||||
set_error_msg.set(Some(format!("Network Error: {}", e)));
|
||||
set_loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
uri.set(String::new());
|
||||
is_loading.1.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
logging::error!("Serialization error: {}", e);
|
||||
show_toast_with_signal(notifications, NotificationLevel::Error, "İstek hatası");
|
||||
set_error_msg.set(Some(format!("Request Error: {}", e)));
|
||||
set_loading.set(false);
|
||||
log::error!("Failed to add torrent: {:?}", e);
|
||||
error_msg.1.set(Some(format!("Hata: {:?}", e)));
|
||||
is_loading.1.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let handle_close = move |_| {
|
||||
if let Some(dialog) = dialog_ref.get() {
|
||||
dialog.close();
|
||||
}
|
||||
on_close.call(());
|
||||
};
|
||||
|
||||
view! {
|
||||
<dialog node_ref=dialog_ref class="modal modal-bottom sm:modal-middle">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">"Add Torrent"</h3>
|
||||
<p class="py-4">"Enter a Magnet URI or direct URL to a .torrent file."</p>
|
||||
<DialogBody>
|
||||
<DialogHeader>
|
||||
<DialogTitle>"Add Torrent"</DialogTitle>
|
||||
<DialogDescription>
|
||||
"Enter a Magnet link or a .torrent file URL."
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form on:submit=handle_submit class="space-y-4 pt-4">
|
||||
<Input
|
||||
r#type=InputType::Text
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
bind_value=uri
|
||||
disabled=is_loading.0.get()
|
||||
/>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
class="input input-bordered w-full"
|
||||
prop:value=uri
|
||||
on:input=move |ev| set_uri.set(event_target_value(&ev))
|
||||
disabled=is_loading
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn" on:click=handle_close disabled=is_loading>"Cancel"</button>
|
||||
<button class="btn btn-primary" on:click=handle_submit disabled=is_loading>
|
||||
{move || if is_loading.get() {
|
||||
view! { <span class="loading loading-spinner"></span> "Adding..." }.into_view()
|
||||
} else {
|
||||
view! { "Add" }.into_view()
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{move || error_msg.get().map(|msg| view! {
|
||||
<div class="text-error text-sm mt-2">{msg}</div>
|
||||
{move || error_msg.0.get().map(|msg| view! {
|
||||
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{msg}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" on:click=handle_close>"close"</button>
|
||||
|
||||
<DialogFooter class="pt-2">
|
||||
<DialogClose>
|
||||
"Cancel"
|
||||
</DialogClose>
|
||||
<Button
|
||||
attr:r#type="submit"
|
||||
attr:disabled=move || is_loading.0.get()
|
||||
>
|
||||
{move || if is_loading.0.get() {
|
||||
leptos::either::Either::Left(view! {
|
||||
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
||||
"Adding..."
|
||||
})
|
||||
} else {
|
||||
leptos::either::Either::Right(view! { "Add" })
|
||||
}}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</dialog>
|
||||
</DialogBody>
|
||||
}
|
||||
}
|
||||
|
||||
239
frontend/src/components/torrent/details.rs
Normal file
239
frontend/src/components/torrent/details.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::components::ui::sheet::*;
|
||||
use crate::components::ui::tabs::*;
|
||||
use crate::components::ui::skeleton::*;
|
||||
use shared::Torrent;
|
||||
|
||||
#[component]
|
||||
pub fn TorrentDetailsSheet() -> impl IntoView {
|
||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||
|
||||
// Setup an effect to open the sheet when a torrent is selected
|
||||
Effect::new(move |_| {
|
||||
if store.selected_torrent.get().is_some() {
|
||||
if let Some(trigger) = document().get_element_by_id("torrent-details-trigger") {
|
||||
use wasm_bindgen::JsCast;
|
||||
let _ = trigger.dyn_into::<web_sys::HtmlElement>().map(|el| el.click());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let selected_torrent = Memo::new(move |_| {
|
||||
let hash = store.selected_torrent.get()?;
|
||||
store.torrents.with(|map| map.get(&hash).cloned())
|
||||
});
|
||||
|
||||
view! {
|
||||
<Sheet>
|
||||
<SheetTrigger attr:id="torrent-details-trigger" class="hidden">""</SheetTrigger>
|
||||
<SheetContent
|
||||
direction=SheetDirection::Bottom
|
||||
class="h-[80vh] sm:h-[60vh] rounded-t-xl sm:rounded-t-2xl p-0 flex flex-col gap-0 border-t border-border shadow-2xl"
|
||||
hide_close_button=true
|
||||
>
|
||||
<div class="px-6 py-4 border-b flex items-center justify-between sticky top-0 bg-card z-10">
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
<Show when=move || selected_torrent.get().is_some() fallback=move || view! { <Skeleton class="h-6 w-48" /> }>
|
||||
<h2 class="font-bold text-lg truncate">
|
||||
{move || selected_torrent.get().map(|t| t.name).unwrap_or_default()}
|
||||
</h2>
|
||||
</Show>
|
||||
<Show when=move || selected_torrent.get().is_some() fallback=move || view! { <Skeleton class="h-4 w-24" /> }>
|
||||
<p class="text-xs text-muted-foreground uppercase tracking-widest font-semibold flex items-center gap-2">
|
||||
{move || selected_torrent.get().map(|t| format!("{:?}", t.status)).unwrap_or_default()}
|
||||
<span class="bg-primary/20 text-primary px-1.5 py-0.5 rounded text-[10px] lowercase">{move || selected_torrent.get().map(|t| format!("{:.1}%", t.percent_complete)).unwrap_or_default()}</span>
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
// Custom close button that also resets store.selected_torrent
|
||||
<SheetClose class="rounded-full p-2 hover:bg-muted transition-colors border-none shadow-none cursor-pointer">
|
||||
<icons::X class="size-5 opacity-70" on:click=move |_| store.selected_torrent.set(None) />
|
||||
</SheetClose>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden p-6">
|
||||
<Tabs default_value="general" class="h-full flex flex-col">
|
||||
<TabsList class="w-full justify-start rounded-none border-b bg-transparent p-0">
|
||||
<TabsTrigger value="general" class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none">
|
||||
"Genel"
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="files" class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none">
|
||||
"Dosyalar"
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="trackers" class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none">
|
||||
"İzleyiciler"
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="peers" class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none">
|
||||
"Eşler"
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div class="flex-1 overflow-y-auto mt-4 pb-12">
|
||||
<TabsContent value="general" class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<crate::components::ui::shimmer::Shimmer
|
||||
loading=Signal::derive(move || selected_torrent.get().is_none())
|
||||
shimmer_color="rgba(0,0,0,0.06)"
|
||||
background_color="rgba(0,0,0,0.04)"
|
||||
>
|
||||
{move || {
|
||||
let t = selected_torrent.get().unwrap_or_else(|| shared::Torrent {
|
||||
hash: "----------------------------------------".to_string(),
|
||||
name: "Yükleniyor...".to_string(),
|
||||
size: 0,
|
||||
completed: 0,
|
||||
down_rate: 0,
|
||||
up_rate: 0,
|
||||
eta: 0,
|
||||
percent_complete: 0.0,
|
||||
status: shared::TorrentStatus::Downloading,
|
||||
error_message: "".to_string(),
|
||||
added_date: 0,
|
||||
label: None,
|
||||
ratio: 0.0,
|
||||
uploaded: 0,
|
||||
wasted: 0,
|
||||
save_path: "Yükleniyor...".to_string(),
|
||||
free_disk_space: 0,
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col gap-6">
|
||||
// Aktarım
|
||||
<div>
|
||||
<h3 class="text-sm font-bold border-b pb-2 mb-4">"Aktarım"</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<InfoItem label="Geçen Süre" value="N/A".to_string() />
|
||||
<InfoItem label="Kalan" value=format_duration(t.eta) />
|
||||
<InfoItem label="Paylaşım Oranı" value=format!("{:.3}", t.ratio) />
|
||||
<div class="hidden md:block"></div>
|
||||
|
||||
<InfoItem label="İndirilen" value=format_bytes(t.completed) />
|
||||
<InfoItem label="İndirme Hızı" value=format_speed(t.down_rate) class="text-blue-500" />
|
||||
<InfoItem label="Boşa Giden" value=format_bytes(t.wasted) />
|
||||
<div class="hidden md:block"></div>
|
||||
|
||||
<InfoItem label="Gönderilen" value=format_bytes(t.uploaded) />
|
||||
<InfoItem label="Gönderme Hızı" value=format_speed(t.up_rate) class="text-green-500" />
|
||||
<div class="hidden md:block"></div>
|
||||
<div class="hidden md:block"></div>
|
||||
|
||||
<InfoItem label="Ortaklar" value="0 / 0 bağlı".to_string() />
|
||||
<InfoItem label="Eşler" value="0 / 0 bağlı".to_string() />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// İzleyici
|
||||
<div>
|
||||
<h3 class="text-sm font-bold border-b pb-2 mb-4">"İzleyici"</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InfoItem label="İzleyici URL'si" value="İzleyiciler sekmesine bakınız".to_string() class="col-span-1 md:col-span-2 break-all text-xs" />
|
||||
<InfoItem label="İzleyici Durumu" value="N/A".to_string() />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Genel
|
||||
<div>
|
||||
<h3 class="text-sm font-bold border-b pb-2 mb-4">"Genel"</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InfoItem label="Kaydedilen Yer" value=t.save_path class="col-span-1 md:col-span-2 break-all font-mono text-xs" />
|
||||
<InfoItem label="Boş Disk Alanı" value=format_bytes(t.free_disk_space) />
|
||||
<InfoItem label="Oluşturulma Tarihi" value=format_date(t.added_date) />
|
||||
<InfoItem label="Hash" value=t.hash class="col-span-1 md:col-span-2 break-all font-mono text-xs" />
|
||||
<InfoItem label="Yorum" value="Yok".to_string() class="col-span-1 md:col-span-2 break-words text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</crate::components::ui::shimmer::Shimmer>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files" class="h-full">
|
||||
{move || match selected_torrent.get() {
|
||||
Some(t) => leptos::either::Either::Left(view! {
|
||||
<div class="h-full overflow-y-auto pr-2 pb-8">
|
||||
<crate::components::torrent::files::TorrentFilesTab hash=t.hash />
|
||||
</div>
|
||||
}),
|
||||
None => leptos::either::Either::Right(view! {
|
||||
<div class="flex flex-col items-center justify-center h-48 opacity-60">
|
||||
<icons::File class="size-12 mb-3 text-muted-foreground" />
|
||||
<p class="text-sm font-medium">"Dosya yükleniyor..."</p>
|
||||
</div>
|
||||
}),
|
||||
}}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="trackers" class="h-full">
|
||||
{move || match selected_torrent.get() {
|
||||
Some(t) => leptos::either::Either::Left(view! {
|
||||
<div class="h-full overflow-y-auto pr-2 pb-8">
|
||||
<crate::components::torrent::trackers::TorrentTrackersTab hash=t.hash />
|
||||
</div>
|
||||
}),
|
||||
None => leptos::either::Either::Right(view! {
|
||||
<div class="flex flex-col items-center justify-center h-48 opacity-60">
|
||||
<icons::Settings2 class="size-12 mb-3 text-muted-foreground" />
|
||||
<p class="text-sm font-medium">"İzleyici yükleniyor..."</p>
|
||||
</div>
|
||||
}),
|
||||
}}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="peers" class="h-full">
|
||||
<div class="flex flex-col items-center justify-center h-48 opacity-60">
|
||||
<icons::Users class="size-12 mb-3 text-muted-foreground" />
|
||||
<p class="text-sm font-medium">"Eş listesi yakında eklenecek"</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn InfoItem(
|
||||
label: &'static str,
|
||||
value: String,
|
||||
#[prop(optional)] class: &'static str
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class=tailwind_fuse::tw_merge!("flex flex-col gap-1", class)>
|
||||
<span class="text-xs font-semibold text-muted-foreground uppercase opacity-80">{label}</span>
|
||||
<span class="text-sm font-medium">{value}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: i64) -> String {
|
||||
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
if bytes < 1024 { return format!("{} B", bytes); }
|
||||
let i = (bytes as f64).log2().div_euclid(10.0) as usize;
|
||||
format!("{:.1} {}", (bytes as f64) / 1024_f64.powi(i as i32), UNITS[i])
|
||||
}
|
||||
|
||||
fn format_speed(bytes_per_sec: i64) -> String {
|
||||
if bytes_per_sec == 0 { return "0 B/s".to_string(); }
|
||||
format!("{}/s", format_bytes(bytes_per_sec))
|
||||
}
|
||||
|
||||
fn format_duration(seconds: i64) -> String {
|
||||
if seconds <= 0 { return "∞".to_string(); }
|
||||
let days = seconds / 86400;
|
||||
let hours = (seconds % 86400) / 3600;
|
||||
let minutes = (seconds % 3600) / 60;
|
||||
let secs = seconds % 60;
|
||||
if days > 0 { format!("{}g {}s", days, hours) }
|
||||
else if hours > 0 { format!("{}s {}d", hours, minutes) }
|
||||
else if minutes > 0 { format!("{}d {}sn", minutes, secs) }
|
||||
else { format!("{}sn", secs) }
|
||||
}
|
||||
|
||||
fn format_date(timestamp: i64) -> String {
|
||||
if timestamp <= 0 { return "N/A".to_string(); }
|
||||
let dt = chrono::DateTime::from_timestamp(timestamp, 0);
|
||||
match dt { Some(dt) => dt.format("%d/%m/%Y %H:%M").to_string(), None => "N/A".to_string() }
|
||||
}
|
||||
216
frontend/src/components/torrent/files.rs
Normal file
216
frontend/src/components/torrent/files.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::components::ui::table::*;
|
||||
use crate::components::ui::badge::*;
|
||||
use crate::components::ui::shimmer::*;
|
||||
use crate::components::ui::context_menu::*;
|
||||
use shared::TorrentFile;
|
||||
|
||||
#[component]
|
||||
pub fn TorrentFilesTab(hash: String) -> impl IntoView {
|
||||
let hash_clone = hash.clone();
|
||||
|
||||
// Fetch files resource
|
||||
let files_resource = Resource::new(
|
||||
move || hash_clone.clone(),
|
||||
|h| async move { shared::server_fns::torrent::get_files(h).await.unwrap_or_default() }
|
||||
);
|
||||
|
||||
// Callback to trigger a refetch — safe, doesn't destroy existing components
|
||||
let on_refresh = Callback::new(move |_: ()| {
|
||||
files_resource.refetch();
|
||||
});
|
||||
|
||||
let stored_hash = StoredValue::new(hash);
|
||||
|
||||
view! {
|
||||
<Suspense fallback=move || view! { <FilesFallback /> }>
|
||||
{move || {
|
||||
let files = files_resource.get().unwrap_or_default();
|
||||
|
||||
if files.is_empty() {
|
||||
return view! {
|
||||
<div class="flex flex-col items-center justify-center h-48 opacity-60">
|
||||
<icons::File class="size-12 mb-3 text-muted-foreground" />
|
||||
<p class="text-sm font-medium">"Bu torrent için dosya bulunamadı."</p>
|
||||
</div>
|
||||
}.into_any();
|
||||
}
|
||||
|
||||
let files_len = files.len();
|
||||
|
||||
view! {
|
||||
<div class="space-y-4">
|
||||
<TableWrapper class="bg-card/50">
|
||||
<Table>
|
||||
<TableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
||||
<TableRow class="hover:bg-transparent">
|
||||
<TableHead class="w-12 text-center text-xs">"#"</TableHead>
|
||||
<TableHead class="text-xs">"Dosya Adı"</TableHead>
|
||||
<TableHead class="w-24 text-right text-xs">"Boyut"</TableHead>
|
||||
<TableHead class="w-24 text-right text-xs">"Tamamlanan"</TableHead>
|
||||
<TableHead class="w-24 text-center text-xs">"Öncelik"</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<For
|
||||
each=move || files.clone()
|
||||
key=|f| f.index
|
||||
children={move |f| {
|
||||
let p_hash = stored_hash.get_value();
|
||||
view! {
|
||||
<FileRow
|
||||
file=f
|
||||
hash=p_hash
|
||||
on_refresh=on_refresh.clone()
|
||||
/>
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableWrapper>
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground px-1">
|
||||
<span>{format!("Toplam {} dosya", files_len)}</span>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn FileRow(file: TorrentFile, hash: String, on_refresh: Callback<()>) -> impl IntoView {
|
||||
let f_idx = file.index;
|
||||
let path_clone = file.path.clone();
|
||||
|
||||
// on_refresh is called AFTER the server responds, not before
|
||||
let on_refresh_stored = StoredValue::new(on_refresh);
|
||||
|
||||
let set_priority = Action::new(move |req: &(String, u32, u8)| {
|
||||
let (h, idx, p) = req.clone();
|
||||
async move {
|
||||
let res = shared::server_fns::torrent::set_file_priority(h, idx, p).await;
|
||||
if let Err(e) = &res {
|
||||
crate::store::show_toast(shared::NotificationLevel::Error, format!("Öncelik değiştirilemedi: {:?}", e));
|
||||
} else {
|
||||
crate::store::show_toast(shared::NotificationLevel::Success, "Dosya önceliği güncellendi.".to_string());
|
||||
// Refetch AFTER the server has saved the priority
|
||||
on_refresh_stored.get_value().run(());
|
||||
}
|
||||
res
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<FileContextMenu
|
||||
torrent_hash=hash
|
||||
file_index=f_idx
|
||||
set_priority=set_priority
|
||||
>
|
||||
<TableRow class="hover:bg-muted/50 transition-colors group">
|
||||
<TableCell class="text-center text-xs text-muted-foreground">{file.index}</TableCell>
|
||||
<TableCell class="font-medium text-xs break-all max-w-[200px] md:max-w-md" attr:title=move || path_clone.clone()>
|
||||
{file.path.clone()}
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-xs text-muted-foreground whitespace-nowrap">
|
||||
{format_bytes(file.size)}
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-xs whitespace-nowrap">
|
||||
<span class="text-primary font-medium">{format_bytes(file.completed_chunks)}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
{
|
||||
let (variant, label) = match file.priority {
|
||||
0 => (BadgeVariant::Destructive, "İndirme"),
|
||||
2 => (BadgeVariant::Success, "Yüksek"),
|
||||
_ => (BadgeVariant::Secondary, "Normal"),
|
||||
};
|
||||
view! { <Badge variant=variant class="text-[10px] uppercase">{label}</Badge> }
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</FileContextMenu>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn FileContextMenu(
|
||||
children: Children,
|
||||
torrent_hash: String,
|
||||
file_index: u32,
|
||||
set_priority: Action<(String, u32, u8), Result<(), ServerFnError>>,
|
||||
) -> impl IntoView {
|
||||
let hash_c1 = torrent_hash.clone();
|
||||
let hash_c2 = torrent_hash.clone();
|
||||
let hash_c3 = torrent_hash.clone();
|
||||
|
||||
view! {
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
{children()}
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent class="w-48">
|
||||
<ContextMenuLabel>"Dosya Önceliği"</ContextMenuLabel>
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuItem on:click={
|
||||
let h = hash_c1;
|
||||
let sp = set_priority.clone();
|
||||
move |_| {
|
||||
sp.dispatch((h.clone(), file_index, 2));
|
||||
crate::components::ui::context_menu::close_context_menu();
|
||||
}
|
||||
}>
|
||||
<icons::ChevronsUp class="text-green-500" />
|
||||
<span>"Yüksek"</span>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem on:click={
|
||||
let h = hash_c2;
|
||||
let sp = set_priority.clone();
|
||||
move |_| {
|
||||
sp.dispatch((h.clone(), file_index, 1));
|
||||
crate::components::ui::context_menu::close_context_menu();
|
||||
}
|
||||
}>
|
||||
<icons::Minus class="text-blue-500" />
|
||||
<span>"Normal"</span>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem class="text-destructive focus:bg-destructive/10" on:click={
|
||||
let h = hash_c3;
|
||||
let sp = set_priority.clone();
|
||||
move |_| {
|
||||
sp.dispatch((h.clone(), file_index, 0));
|
||||
crate::components::ui::context_menu::close_context_menu();
|
||||
}
|
||||
}>
|
||||
<icons::X />
|
||||
<span>"İndirme (Kapalı)"</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn FilesFallback() -> impl IntoView {
|
||||
view! {
|
||||
<Shimmer loading=Signal::derive(|| true) class="space-y-2">
|
||||
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
|
||||
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
|
||||
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
|
||||
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
|
||||
</Shimmer>
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
pub mod table;
|
||||
pub mod add_torrent;
|
||||
pub mod details;
|
||||
pub mod files;
|
||||
pub mod trackers;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
121
frontend/src/components/torrent/trackers.rs
Normal file
121
frontend/src/components/torrent/trackers.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::components::ui::table::*;
|
||||
use crate::components::ui::shimmer::*;
|
||||
use shared::TorrentTracker;
|
||||
|
||||
#[component]
|
||||
pub fn TorrentTrackersTab(hash: String) -> impl IntoView {
|
||||
let hash_clone = hash.clone();
|
||||
|
||||
let trackers_resource = Resource::new(
|
||||
move || hash_clone.clone(),
|
||||
|h| async move { shared::server_fns::torrent::get_trackers(h).await.unwrap_or_default() }
|
||||
);
|
||||
|
||||
view! {
|
||||
<Suspense fallback=move || view! { <TrackersFallback /> }>
|
||||
{move || {
|
||||
let trackers = trackers_resource.get().unwrap_or_default();
|
||||
|
||||
if trackers.is_empty() {
|
||||
return view! {
|
||||
<div class="flex flex-col items-center justify-center h-48 opacity-60">
|
||||
<icons::Settings2 class="size-12 mb-3 text-muted-foreground" />
|
||||
<p class="text-sm font-medium">"Bu torrent için izleyici bulunamadı."</p>
|
||||
</div>
|
||||
}.into_any();
|
||||
}
|
||||
|
||||
view! {
|
||||
<div class="h-full overflow-auto">
|
||||
<TableWrapper class="bg-card/50 whitespace-nowrap">
|
||||
<Table>
|
||||
<TableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
||||
<TableRow class="hover:bg-transparent text-xs">
|
||||
<TableHead>"İsim"</TableHead>
|
||||
<TableHead class="text-center">"Tür"</TableHead>
|
||||
<TableHead class="text-center">"Etkin"</TableHead>
|
||||
<TableHead class="text-center">"Grup"</TableHead>
|
||||
<TableHead class="text-center">"Ortaklar"</TableHead>
|
||||
<TableHead class="text-center">"Eşler"</TableHead>
|
||||
<TableHead class="text-center">"İndirilen"</TableHead>
|
||||
<TableHead class="text-center">"Son Güncelleme"</TableHead>
|
||||
<TableHead class="text-center">"Sıklık"</TableHead>
|
||||
<TableHead class="text-center">"Özel"</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<For
|
||||
each=move || trackers.clone()
|
||||
key=|t| t.url.clone()
|
||||
children=move |t| {
|
||||
let t_type = if t.url.starts_with("http") { "http" }
|
||||
else if t.url.starts_with("udp") { "udp" }
|
||||
else if t.url.starts_with("dht") { "dht" }
|
||||
else { "diğer" };
|
||||
let is_enabled = if t.is_enabled { "evet" } else { "hayır" };
|
||||
|
||||
// Format timestamp difference for last update
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let diff = now - t.last_updated;
|
||||
let last_update_str = if t.last_updated == 0 {
|
||||
"Güncellenmedi".to_string()
|
||||
} else if diff >= 0 {
|
||||
format_duration_short(diff)
|
||||
} else {
|
||||
"N/A".to_string()
|
||||
};
|
||||
|
||||
let url_clone = t.url.clone();
|
||||
view! {
|
||||
<TableRow class="hover:bg-muted/50 transition-colors group text-xs text-muted-foreground">
|
||||
<TableCell class="font-medium text-foreground max-w-[200px] md:max-w-md truncate" attr:title=url_clone>
|
||||
{t.url.clone()}
|
||||
</TableCell>
|
||||
<TableCell class="text-center">{t_type}</TableCell>
|
||||
<TableCell class="text-center">{is_enabled}</TableCell>
|
||||
<TableCell class="text-center">{t.group}</TableCell>
|
||||
<TableCell class="text-center">{t.seeders}</TableCell>
|
||||
<TableCell class="text-center">{t.peers}</TableCell>
|
||||
<TableCell class="text-center">{t.downloaded}</TableCell>
|
||||
<TableCell class="text-center">{last_update_str}</TableCell>
|
||||
<TableCell class="text-center">{format_duration_short(t.interval)}</TableCell>
|
||||
<TableCell class="text-center">"bilinmiyor"</TableCell> // Özel flag isn't cleanly via XMLRPC per tracker usually
|
||||
</TableRow>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableWrapper>
|
||||
</div>
|
||||
}.into_any()
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TrackersFallback() -> impl IntoView {
|
||||
view! {
|
||||
<Shimmer loading=Signal::derive(|| true) class="space-y-2">
|
||||
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
|
||||
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
|
||||
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
|
||||
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
|
||||
</Shimmer>
|
||||
}
|
||||
}
|
||||
|
||||
fn format_duration_short(seconds: i64) -> String {
|
||||
if seconds <= 0 { return "0sn".to_string(); }
|
||||
let days = seconds / 86400;
|
||||
let hours = (seconds % 86400) / 3600;
|
||||
let minutes = (seconds % 3600) / 60;
|
||||
let secs = seconds % 60;
|
||||
|
||||
if days > 0 { format!("{}g {}s", days, hours) }
|
||||
else if hours > 0 { format!("{}s {}dk", hours, minutes) }
|
||||
else if minutes > 0 { format!("{}dk {}sn", minutes, secs) }
|
||||
else { format!("{}sn", secs) }
|
||||
}
|
||||
39
frontend/src/components/ui/accordion.rs
Normal file
39
frontend/src/components/ui/accordion.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
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> }
|
||||
}
|
||||
94
frontend/src/components/ui/alert_dialog.rs
Normal file
94
frontend/src/components/ui/alert_dialog.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::components::ui::button::{ButtonSize, ButtonVariant};
|
||||
use crate::components::ui::dialog::{
|
||||
Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialog(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
view! { <Dialog class=class>{children()}</Dialog> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogTrigger(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
|
||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<DialogTrigger class=class variant=variant size=size>
|
||||
{children()}
|
||||
</DialogTrigger>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<DialogContent class=class close_on_backdrop_click=false data_name_prefix="AlertDialog">
|
||||
{children()}
|
||||
</DialogContent>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogBody(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<DialogBody class=class attr:data-name="AlertDialogBody">
|
||||
{children()}
|
||||
</DialogBody>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<DialogHeader class=class attr:data-name="AlertDialogHeader">
|
||||
{children()}
|
||||
</DialogHeader>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogTitle(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<DialogTitle class=class attr:data-name="AlertDialogTitle">
|
||||
{children()}
|
||||
</DialogTitle>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogDescription(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<DialogDescription class=class attr:data-name="AlertDialogDescription">
|
||||
{children()}
|
||||
</DialogDescription>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogFooter(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<DialogFooter class=class attr:data-name="AlertDialogFooter">
|
||||
{children()}
|
||||
</DialogFooter>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlertDialogClose(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
|
||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<DialogClose class=class variant=variant size=size>
|
||||
{children()}
|
||||
</DialogClose>
|
||||
}
|
||||
}
|
||||
43
frontend/src/components/ui/badge.rs
Normal file
43
frontend/src/components/ui/badge.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
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>
|
||||
}
|
||||
}
|
||||
39
frontend/src/components/ui/button.rs
Normal file
39
frontend/src/components/ui/button.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_ui::variants;
|
||||
|
||||
// TODO 💪 Loading state (demo_use_timeout_fn.rs and demo_button.rs)
|
||||
|
||||
variants! {
|
||||
Button {
|
||||
base: "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive w-fit hover:cursor-pointer active:scale-[0.98] active:opacity-100 touch-manipulation [-webkit-tap-highlight-color:transparent] select-none [-webkit-touch-callout:none]", // Using hover:cursor-pointer as workaround for href_support.
|
||||
variants: {
|
||||
variant: {
|
||||
Default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
Destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
Outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/5",
|
||||
Secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
Ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
Accent: "bg-accent text-accent-foreground hover:bg-accent/80",
|
||||
Link: "text-primary underline-offset-4 hover:underline",
|
||||
//
|
||||
Warning: "bg-warning text-warning-foreground hover:bg-warning/90",
|
||||
Success: "bg-success text-success-foreground hover:bg-success/90",
|
||||
Bordered: "bg-transparent border border-zinc-200 text-muted-foreground",
|
||||
},
|
||||
size: {
|
||||
Default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
Sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
Lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
Icon: "size-9",
|
||||
//
|
||||
Mobile: "px-6 py-3 rounded-[24px]",
|
||||
Badge: "px-2.5 py-0.5 text-xs"
|
||||
}
|
||||
},
|
||||
component: {
|
||||
element: button,
|
||||
support_href: true,
|
||||
support_aria_current: true
|
||||
}
|
||||
}
|
||||
}
|
||||
72
frontend/src/components/ui/button_action.rs
Normal file
72
frontend/src/components/ui/button_action.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
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>
|
||||
}
|
||||
}
|
||||
19
frontend/src/components/ui/card.rs
Normal file
19
frontend/src/components/ui/card.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_ui::clx;
|
||||
|
||||
mod components {
|
||||
use super::*;
|
||||
clx! {Card, div, "bg-card text-card-foreground flex flex-col gap-4 rounded-xl border py-6 shadow-sm"}
|
||||
// TODO. Change data-slot=card-action by data-name="CardAction".
|
||||
clx! {CardHeader, div, "@container/card-header flex flex-col items-start gap-1.5 px-6 [.border-b]:pb-6 sm:grid sm:auto-rows-min sm:grid-rows-[auto_auto] has-data-[slot=card-action]:sm:grid-cols-[1fr_auto]"}
|
||||
clx! {CardTitle, h2, "leading-none font-semibold"}
|
||||
clx! {CardContent, div, "px-6"}
|
||||
clx! {CardDescription, p, "text-muted-foreground text-sm"}
|
||||
clx! {CardFooter, footer, "flex items-center px-6 [.border-t]:pt-6", "gap-2"}
|
||||
|
||||
clx! {CardAction, div, "self-start sm:col-start-2 sm:row-span-2 sm:row-start-1 sm:justify-self-end"}
|
||||
clx! {CardList, ul, "flex flex-col gap-4"}
|
||||
clx! {CardItem, li, "flex items-center [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"}
|
||||
}
|
||||
|
||||
pub use components::*;
|
||||
43
frontend/src/components/ui/checkbox.rs
Normal file
43
frontend/src/components/ui/checkbox.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use icons::Check;
|
||||
use leptos::prelude::*;
|
||||
use tw_merge::tw_merge;
|
||||
|
||||
#[component]
|
||||
pub fn Checkbox(
|
||||
#[prop(into, optional)] class: String,
|
||||
#[prop(into, optional)] checked: Signal<bool>,
|
||||
#[prop(into, optional)] disabled: Signal<bool>,
|
||||
#[prop(into, optional)] on_checked_change: Option<Callback<bool>>,
|
||||
#[prop(into, optional, default = "Checkbox".to_string())] aria_label: String,
|
||||
) -> impl IntoView {
|
||||
let checked_state = move || if checked.get() { "checked" } else { "unchecked" };
|
||||
|
||||
let checkbox_class = tw_merge!(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
class
|
||||
);
|
||||
|
||||
view! {
|
||||
<button
|
||||
data-name="Checkbox"
|
||||
class=checkbox_class
|
||||
data-state=checked_state
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked=move || checked.get().to_string()
|
||||
aria-label=aria_label
|
||||
disabled=move || disabled.get()
|
||||
on:click=move |_| {
|
||||
if !disabled.get() {
|
||||
if let Some(callback) = on_checked_change {
|
||||
callback.run(!checked.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
<span data-name="CheckboxIndicator" class="flex justify-center items-center text-current transition-none">
|
||||
{move || { checked.get().then(|| view! { <Check class="size-3.5".to_string() /> }) }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
419
frontend/src/components/ui/context_menu.rs
Normal file
419
frontend/src/components/ui/context_menu.rs
Normal file
@@ -0,0 +1,419 @@
|
||||
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 = Math.max(0, x - menuRect.width);
|
||||
}}
|
||||
|
||||
// Adjust if menu would go off bottom edge
|
||||
if (y + menuRect.height > viewportHeight) {{
|
||||
top = Math.max(0, y - menuRect.height);
|
||||
}}
|
||||
|
||||
// Adjust for CSS transformed containing block
|
||||
const offsetParent = menu.offsetParent;
|
||||
if (offsetParent && offsetParent !== document.body && offsetParent !== document.documentElement) {{
|
||||
const parentRect = offsetParent.getBoundingClientRect();
|
||||
left -= parentRect.left;
|
||||
top -= parentRect.top;
|
||||
}}
|
||||
|
||||
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 (desktop)
|
||||
trigger.addEventListener('contextmenu', (e) => {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (isOpen) {{
|
||||
closeMenu();
|
||||
}}
|
||||
openMenu(e.clientX, e.clientY);
|
||||
}});
|
||||
|
||||
// Long-press on trigger (mobile)
|
||||
let touchTimer = null;
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
const LONG_PRESS_DURATION = 500;
|
||||
const MOVE_THRESHOLD = 10;
|
||||
|
||||
trigger.addEventListener('touchstart', (e) => {{
|
||||
const touch = e.touches[0];
|
||||
touchStartX = touch.clientX;
|
||||
touchStartY = touch.clientY;
|
||||
|
||||
touchTimer = setTimeout(() => {{
|
||||
e.preventDefault();
|
||||
if (isOpen) {{
|
||||
closeMenu();
|
||||
}}
|
||||
openMenu(touchStartX, touchStartY);
|
||||
}}, LONG_PRESS_DURATION);
|
||||
}}, {{ passive: false }});
|
||||
|
||||
trigger.addEventListener('touchmove', (e) => {{
|
||||
if (touchTimer) {{
|
||||
const touch = e.touches[0];
|
||||
const dx = Math.abs(touch.clientX - touchStartX);
|
||||
const dy = Math.abs(touch.clientY - touchStartY);
|
||||
if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) {{
|
||||
clearTimeout(touchTimer);
|
||||
touchTimer = null;
|
||||
}}
|
||||
}}
|
||||
}});
|
||||
|
||||
trigger.addEventListener('touchend', () => {{
|
||||
if (touchTimer) {{
|
||||
clearTimeout(touchTimer);
|
||||
touchTimer = null;
|
||||
}}
|
||||
}});
|
||||
|
||||
trigger.addEventListener('touchcancel', () => {{
|
||||
if (touchTimer) {{
|
||||
clearTimeout(touchTimer);
|
||||
touchTimer = null;
|
||||
}}
|
||||
}});
|
||||
|
||||
// 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>
|
||||
}
|
||||
}
|
||||
6
frontend/src/components/ui/data_table.rs
Normal file
6
frontend/src/components/ui/data_table.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// * Reuse @table.rs
|
||||
pub use crate::components::ui::table::{
|
||||
Table as DataTable, TableBody as DataTableBody, TableCaption as DataTableCaption, TableCell as DataTableCell,
|
||||
TableFooter as DataTableFooter, TableHead as DataTableHead, TableHeader as DataTableHeader,
|
||||
TableRow as DataTableRow, TableWrapper as DataTableWrapper,
|
||||
};
|
||||
238
frontend/src/components/ui/dialog.rs
Normal file
238
frontend/src/components/ui/dialog.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
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>
|
||||
}
|
||||
}
|
||||
536
frontend/src/components/ui/dropdown_menu.rs
Normal file
536
frontend/src/components/ui/dropdown_menu.rs
Normal file
@@ -0,0 +1,536 @@
|
||||
use icons::{Check, ChevronRight};
|
||||
use leptos::context::Provider;
|
||||
use leptos::prelude::*;
|
||||
use leptos_ui::clx;
|
||||
use tw_merge::*;
|
||||
|
||||
use crate::components::hooks::use_random::use_random_id_for;
|
||||
pub use crate::components::ui::separator::Separator as DropdownMenuSeparator;
|
||||
|
||||
mod components {
|
||||
use super::*;
|
||||
clx! {DropdownMenuLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
|
||||
clx! {DropdownMenuGroup, ul, "group"}
|
||||
clx! {DropdownMenuItem, li, "inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4"}
|
||||
clx! {DropdownMenuSubContent, ul, "dropdown__menu_sub_content", "rounded-md border bg-card shadow-lg p-1 absolute z-[100] min-w-[160px] opacity-0 invisible translate-x-[-8px] transition-all duration-200 ease-out pointer-events-none"}
|
||||
clx! {DropdownMenuLink, a, "w-full inline-flex gap-2 items-center"}
|
||||
}
|
||||
|
||||
pub use components::*;
|
||||
|
||||
/* ========================================================== */
|
||||
/* RADIO GROUP */
|
||||
/* ========================================================== */
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DropdownMenuRadioContext<T: Clone + PartialEq + Send + Sync + 'static> {
|
||||
value_signal: RwSignal<T>,
|
||||
}
|
||||
|
||||
/// A group of radio items where only one can be selected at a time.
|
||||
#[component]
|
||||
pub fn DropdownMenuRadioGroup<T>(
|
||||
children: Children,
|
||||
/// The signal holding the current selected value
|
||||
value: RwSignal<T>,
|
||||
) -> impl IntoView
|
||||
where
|
||||
T: Clone + PartialEq + Send + Sync + 'static,
|
||||
{
|
||||
let ctx = DropdownMenuRadioContext { value_signal: value };
|
||||
|
||||
view! {
|
||||
<Provider value=ctx>
|
||||
<ul data-name="DropdownMenuRadioGroup" role="group" class="group">
|
||||
{children()}
|
||||
</ul>
|
||||
</Provider>
|
||||
}
|
||||
}
|
||||
|
||||
/// A radio item that shows a checkmark when selected.
|
||||
#[component]
|
||||
pub fn DropdownMenuRadioItem<T>(
|
||||
children: Children,
|
||||
/// The value this item represents
|
||||
value: T,
|
||||
#[prop(optional, into)] class: String,
|
||||
) -> impl IntoView
|
||||
where
|
||||
T: Clone + PartialEq + Send + Sync + 'static,
|
||||
{
|
||||
let ctx = expect_context::<DropdownMenuRadioContext<T>>();
|
||||
|
||||
let value_for_check = value.clone();
|
||||
let value_for_click = value.clone();
|
||||
let is_selected = move || ctx.value_signal.get() == value_for_check;
|
||||
|
||||
let merged_class = tw_merge!(
|
||||
"group inline-flex gap-2 items-center w-full rounded-sm pl-2 pr-2 py-1.5 text-sm cursor-pointer no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
class
|
||||
);
|
||||
|
||||
view! {
|
||||
<li
|
||||
data-name="DropdownMenuRadioItem"
|
||||
class=merged_class
|
||||
role="menuitemradio"
|
||||
aria-checked=move || is_selected().to_string()
|
||||
data-dropdown-close="true"
|
||||
on:click=move |_| {
|
||||
ctx.value_signal.set(value_for_click.clone());
|
||||
}
|
||||
>
|
||||
{children()}
|
||||
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-checked:opacity-100" />
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
/// An action item in a dropdown menu (no checkmark, just triggers an action).
|
||||
#[component]
|
||||
pub fn DropdownMenuAction(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional, into)] href: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let _ctx = expect_context::<DropdownMenuContext>();
|
||||
|
||||
let class = tw_merge!(
|
||||
"inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground",
|
||||
class
|
||||
);
|
||||
|
||||
if let Some(href) = href {
|
||||
// Render as <a> tag when href is provided
|
||||
view! {
|
||||
<a data-name="DropdownMenuAction" class=class href=href data-dropdown-close="true">
|
||||
{children()}
|
||||
</a>
|
||||
|
||||
<script>
|
||||
{r#"
|
||||
(function() {
|
||||
const link = document.currentScript.previousElementSibling;
|
||||
if (!link) return;
|
||||
|
||||
link.addEventListener('click', function() {
|
||||
// Close dropdown on route change after navigation
|
||||
let currentPath = window.location.pathname;
|
||||
const checkRouteChange = () => {
|
||||
if (window.location.pathname !== currentPath) {
|
||||
currentPath = window.location.pathname;
|
||||
|
||||
// Find and close the dropdown
|
||||
const dropdown = link.closest('[data-target="target__dropdown"]');
|
||||
if (dropdown) {
|
||||
dropdown.setAttribute('data-state', 'closed');
|
||||
dropdown.style.pointerEvents = 'none';
|
||||
|
||||
// Unlock scroll
|
||||
if (window.ScrollLock) {
|
||||
window.ScrollLock.unlock(200);
|
||||
}
|
||||
}
|
||||
|
||||
clearInterval(routeCheckInterval);
|
||||
}
|
||||
};
|
||||
|
||||
const routeCheckInterval = setInterval(checkRouteChange, 50);
|
||||
|
||||
// Clear interval after 2 seconds to prevent memory leaks
|
||||
setTimeout(() => clearInterval(routeCheckInterval), 2000);
|
||||
});
|
||||
})();
|
||||
"#}
|
||||
</script>
|
||||
}
|
||||
.into_any()
|
||||
} else {
|
||||
// Render as <button> tag when no href
|
||||
view! {
|
||||
<button type="button" data-name="DropdownMenuAction" class=class data-dropdown-close="true">
|
||||
{children()}
|
||||
</button>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DropdownMenuAlign {
|
||||
#[default]
|
||||
Start,
|
||||
StartOuter,
|
||||
End,
|
||||
EndOuter,
|
||||
Center,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DropdownMenuContext {
|
||||
target_id: String,
|
||||
align: DropdownMenuAlign,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DropdownMenu(
|
||||
children: Children,
|
||||
#[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign,
|
||||
) -> impl IntoView {
|
||||
let dropdown_target_id = use_random_id_for("dropdown");
|
||||
|
||||
let ctx = DropdownMenuContext { target_id: dropdown_target_id.clone(), align };
|
||||
|
||||
view! {
|
||||
<Provider value=ctx>
|
||||
<style>
|
||||
"
|
||||
/* Submenu Styles */
|
||||
.dropdown__menu_sub_content {
|
||||
position: absolute;
|
||||
inset-inline-start: calc(100% + 8px);
|
||||
inset-block-start: -4px;
|
||||
z-index: 100;
|
||||
min-inline-size: 160px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateX(-8px);
|
||||
transition: all 0.2s ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown__menu_sub_trigger:hover .dropdown__menu_sub_content {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
"
|
||||
</style>
|
||||
|
||||
<div data-name="DropdownMenu">{children()}</div>
|
||||
</Provider>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DropdownMenuTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let ctx = expect_context::<DropdownMenuContext>();
|
||||
let button_class = tw_merge!(
|
||||
"px-4 py-2 h-9 inline-flex justify-center items-center text-sm font-medium whitespace-nowrap rounded-md transition-colors w-fit focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
|
||||
class
|
||||
);
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class=button_class
|
||||
data-name="DropdownMenuTrigger"
|
||||
data-dropdown-trigger=ctx.target_id
|
||||
tabindex="0"
|
||||
>
|
||||
{children()}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DropdownMenuPosition {
|
||||
#[default]
|
||||
Auto,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DropdownMenuContent(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = DropdownMenuPosition::default())] position: DropdownMenuPosition,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<DropdownMenuContext>();
|
||||
|
||||
let base_classes = "z-50 p-1 rounded-md border bg-card shadow-md h-fit fixed transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100";
|
||||
let width_class = match ctx.align {
|
||||
DropdownMenuAlign::Center => "min-w-full",
|
||||
_ => "w-[180px]",
|
||||
};
|
||||
|
||||
let class = tw_merge!(width_class, base_classes, class);
|
||||
|
||||
let target_id_for_script = ctx.target_id.clone();
|
||||
let align_for_script = match ctx.align {
|
||||
DropdownMenuAlign::Start => "start",
|
||||
DropdownMenuAlign::StartOuter => "start-outer",
|
||||
DropdownMenuAlign::End => "end",
|
||||
DropdownMenuAlign::EndOuter => "end-outer",
|
||||
DropdownMenuAlign::Center => "center",
|
||||
};
|
||||
|
||||
let position_for_script = match position {
|
||||
DropdownMenuPosition::Auto => "auto",
|
||||
DropdownMenuPosition::Top => "top",
|
||||
DropdownMenuPosition::Bottom => "bottom",
|
||||
};
|
||||
|
||||
view! {
|
||||
<div
|
||||
data-name="DropdownMenuContent"
|
||||
class=class
|
||||
id=ctx.target_id
|
||||
data-target="target__dropdown"
|
||||
data-state="closed"
|
||||
data-align=align_for_script
|
||||
data-position=position_for_script
|
||||
style="pointer-events: none;"
|
||||
>
|
||||
{children()}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{format!(
|
||||
r#"
|
||||
(function() {{
|
||||
const setupDropdown = () => {{
|
||||
const dropdown = document.querySelector('#{}');
|
||||
const trigger = document.querySelector('[data-dropdown-trigger="{}"]');
|
||||
|
||||
if (!dropdown || !trigger) {{
|
||||
setTimeout(setupDropdown, 50);
|
||||
return;
|
||||
}}
|
||||
|
||||
if (dropdown.hasAttribute('data-initialized')) {{
|
||||
return;
|
||||
}}
|
||||
dropdown.setAttribute('data-initialized', 'true');
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
const updatePosition = () => {{
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const dropdownRect = dropdown.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom;
|
||||
const spaceAbove = triggerRect.top;
|
||||
|
||||
const align = dropdown.getAttribute('data-align') || 'start';
|
||||
const position = dropdown.getAttribute('data-position') || 'auto';
|
||||
|
||||
// Determine if we should position above
|
||||
let shouldPositionAbove = false;
|
||||
if (position === 'top') {{
|
||||
shouldPositionAbove = true;
|
||||
}} else if (position === 'bottom') {{
|
||||
shouldPositionAbove = false;
|
||||
}} else {{
|
||||
// Auto: position above if there's space above AND not enough space below
|
||||
shouldPositionAbove = spaceAbove >= dropdownRect.height && spaceBelow < dropdownRect.height;
|
||||
}}
|
||||
|
||||
switch (align) {{
|
||||
case 'start':
|
||||
if (shouldPositionAbove) {{
|
||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||
dropdown.style.transformOrigin = 'left bottom';
|
||||
}} else {{
|
||||
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
||||
dropdown.style.transformOrigin = 'left top';
|
||||
}}
|
||||
dropdown.style.left = `${{triggerRect.left}}px`;
|
||||
break;
|
||||
|
||||
case 'end':
|
||||
if (shouldPositionAbove) {{
|
||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||
dropdown.style.transformOrigin = 'right bottom';
|
||||
}} else {{
|
||||
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
||||
dropdown.style.transformOrigin = 'right top';
|
||||
}}
|
||||
dropdown.style.left = `${{triggerRect.right - dropdownRect.width}}px`;
|
||||
break;
|
||||
|
||||
case 'start-outer':
|
||||
if (shouldPositionAbove) {{
|
||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||
dropdown.style.transformOrigin = 'right bottom';
|
||||
}} else {{
|
||||
dropdown.style.top = `${{triggerRect.top}}px`;
|
||||
dropdown.style.transformOrigin = 'right top';
|
||||
}}
|
||||
dropdown.style.left = `${{triggerRect.left - dropdownRect.width - 16}}px`;
|
||||
break;
|
||||
|
||||
case 'end-outer':
|
||||
if (shouldPositionAbove) {{
|
||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||
dropdown.style.transformOrigin = 'left bottom';
|
||||
}} else {{
|
||||
dropdown.style.top = `${{triggerRect.top}}px`;
|
||||
dropdown.style.transformOrigin = 'left top';
|
||||
}}
|
||||
dropdown.style.left = `${{triggerRect.right + 8}}px`;
|
||||
break;
|
||||
|
||||
case 'center':
|
||||
if (shouldPositionAbove) {{
|
||||
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||
dropdown.style.transformOrigin = 'center bottom';
|
||||
}} else {{
|
||||
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
||||
dropdown.style.transformOrigin = 'center top';
|
||||
}}
|
||||
dropdown.style.left = `${{triggerRect.left}}px`;
|
||||
dropdown.style.minWidth = `${{triggerRect.width}}px`;
|
||||
break;
|
||||
}}
|
||||
}};
|
||||
|
||||
const openDropdown = () => {{
|
||||
isOpen = true;
|
||||
|
||||
// Set state to open first to remove scale transform for accurate measurements
|
||||
dropdown.setAttribute('data-state', 'open');
|
||||
|
||||
// Make dropdown invisible but rendered to measure true height
|
||||
dropdown.style.visibility = 'hidden';
|
||||
dropdown.style.pointerEvents = 'auto';
|
||||
|
||||
// Force reflow to ensure height is calculated
|
||||
dropdown.offsetHeight;
|
||||
|
||||
// Calculate position with accurate height
|
||||
updatePosition();
|
||||
|
||||
// Now make it visible
|
||||
dropdown.style.visibility = 'visible';
|
||||
|
||||
// Lock all scrollable elements
|
||||
window.ScrollLock.lock();
|
||||
|
||||
// Close on click outside
|
||||
setTimeout(() => {{
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}}, 0);
|
||||
}};
|
||||
|
||||
const closeDropdown = () => {{
|
||||
isOpen = false;
|
||||
dropdown.setAttribute('data-state', 'closed');
|
||||
dropdown.style.pointerEvents = 'none';
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
|
||||
// Unlock scroll after animation (200ms delay)
|
||||
window.ScrollLock.unlock(200);
|
||||
}};
|
||||
|
||||
const handleClickOutside = (e) => {{
|
||||
if (!dropdown.contains(e.target) && !trigger.contains(e.target)) {{
|
||||
closeDropdown();
|
||||
}}
|
||||
}};
|
||||
|
||||
// Toggle dropdown when trigger is clicked
|
||||
trigger.addEventListener('click', (e) => {{
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if any other dropdown is open
|
||||
const allDropdowns = document.querySelectorAll('[data-target=\"target__dropdown\"]');
|
||||
let otherDropdownOpen = false;
|
||||
allDropdowns.forEach(dd => {{
|
||||
if (dd !== dropdown && dd.getAttribute('data-state') === 'open') {{
|
||||
otherDropdownOpen = true;
|
||||
dd.setAttribute('data-state', 'closed');
|
||||
dd.style.pointerEvents = 'none';
|
||||
// Unlock scroll
|
||||
if (window.ScrollLock) {{
|
||||
window.ScrollLock.unlock(200);
|
||||
}}
|
||||
}}
|
||||
}});
|
||||
|
||||
// If another dropdown was open, just close it and don't open this one
|
||||
if (otherDropdownOpen) {{
|
||||
return;
|
||||
}}
|
||||
|
||||
// Normal toggle behavior
|
||||
if (isOpen) {{
|
||||
closeDropdown();
|
||||
}} else {{
|
||||
openDropdown();
|
||||
}}
|
||||
}});
|
||||
|
||||
// Close when action is clicked
|
||||
const actions = dropdown.querySelectorAll('[data-dropdown-close]');
|
||||
actions.forEach(action => {{
|
||||
action.addEventListener('click', () => {{
|
||||
closeDropdown();
|
||||
}});
|
||||
}});
|
||||
|
||||
// Handle ESC key to close
|
||||
document.addEventListener('keydown', (e) => {{
|
||||
if (e.key === 'Escape' && isOpen) {{
|
||||
e.preventDefault();
|
||||
closeDropdown();
|
||||
}}
|
||||
}});
|
||||
}};
|
||||
|
||||
if (document.readyState === 'loading') {{
|
||||
document.addEventListener('DOMContentLoaded', setupDropdown);
|
||||
}} else {{
|
||||
setupDropdown();
|
||||
}}
|
||||
}})();
|
||||
"#,
|
||||
target_id_for_script,
|
||||
target_id_for_script,
|
||||
)}
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DropdownMenuSub(children: Children) -> impl IntoView {
|
||||
// TODO. Find a better way for dropdown__menu_sub_trigger.
|
||||
clx! {DropdownMenuSubRoot, li, "dropdown__menu_sub_trigger", " relative inline-flex relative gap-2 items-center py-1.5 px-2 w-full text-sm no-underline rounded-sm transition-colors duration-200 cursor-pointer text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground"}
|
||||
|
||||
view! { <DropdownMenuSubRoot>{children()}</DropdownMenuSubRoot> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DropdownMenuSubTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!("flex items-center justify-between w-full", class);
|
||||
|
||||
view! {
|
||||
<span attr:data-name="DropdownMenuSubTrigger" class=class>
|
||||
<span class="flex gap-2 items-center">{children()}</span>
|
||||
<ChevronRight class="opacity-70 size-4" />
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DropdownMenuSubItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let class = tw_merge!(
|
||||
"inline-flex gap-2 items-center w-full rounded-sm px-3 py-2 text-sm transition-all duration-150 ease text-popover-foreground hover:bg-accent hover:text-accent-foreground cursor-pointer hover:translate-x-[2px]",
|
||||
class
|
||||
);
|
||||
|
||||
view! {
|
||||
<li data-name="DropdownMenuSubItem" class=class data-dropdown-close="true">
|
||||
{children()}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
35
frontend/src/components/ui/empty.rs
Normal file
35
frontend/src/components/ui/empty.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_ui::{clx, variants};
|
||||
|
||||
mod components {
|
||||
use super::*;
|
||||
clx! {Empty, div, "flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8 text-center"}
|
||||
clx! {EmptyHeader, div, "flex flex-col items-center gap-2"}
|
||||
clx! {EmptyTitle, h3, "text-lg font-semibold leading-none"}
|
||||
clx! {EmptyDescription, p, "text-muted-foreground text-sm"}
|
||||
clx! {EmptyContent, div, "flex items-center justify-center gap-2"}
|
||||
}
|
||||
|
||||
pub use components::*;
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
variants! {
|
||||
EmptyMedia {
|
||||
base: "flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
Default: "bg-transparent",
|
||||
Icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
size: {
|
||||
Default: "",
|
||||
}
|
||||
},
|
||||
component: {
|
||||
element: div
|
||||
}
|
||||
}
|
||||
}
|
||||
99
frontend/src/components/ui/input.rs
Normal file
99
frontend/src/components/ui/input.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use leptos::html;
|
||||
use leptos::prelude::*;
|
||||
use strum::AsRefStr;
|
||||
use tw_merge::tw_merge;
|
||||
|
||||
#[derive(Default, Clone, Copy, PartialEq, Eq, AsRefStr)]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
#[allow(dead_code)]
|
||||
pub enum InputType {
|
||||
#[default]
|
||||
Text,
|
||||
Email,
|
||||
Password,
|
||||
Number,
|
||||
Tel,
|
||||
Url,
|
||||
Search,
|
||||
Date,
|
||||
Time,
|
||||
#[strum(serialize = "datetime-local")]
|
||||
DatetimeLocal,
|
||||
Month,
|
||||
Week,
|
||||
Color,
|
||||
File,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Input(
|
||||
#[prop(into, optional)] class: String,
|
||||
#[prop(default = InputType::default())] r#type: InputType,
|
||||
#[prop(into, optional)] placeholder: Option<String>,
|
||||
#[prop(into, optional)] name: Option<String>,
|
||||
#[prop(into, optional)] id: Option<String>,
|
||||
#[prop(into, optional)] title: Option<String>,
|
||||
#[prop(optional)] disabled: bool,
|
||||
#[prop(optional)] readonly: bool,
|
||||
#[prop(optional)] required: bool,
|
||||
#[prop(optional)] autofocus: bool,
|
||||
#[prop(into, optional)] min: Option<String>,
|
||||
#[prop(into, optional)] max: Option<String>,
|
||||
#[prop(into, optional)] step: Option<String>,
|
||||
#[prop(into, optional)] bind_value: Option<RwSignal<String>>,
|
||||
#[prop(optional)] node_ref: NodeRef<html::Input>,
|
||||
) -> impl IntoView {
|
||||
let merged_class = tw_merge!(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50",
|
||||
"focus-visible:ring-2",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"read-only:bg-muted",
|
||||
class
|
||||
);
|
||||
|
||||
let type_str = r#type.as_ref();
|
||||
|
||||
match bind_value {
|
||||
Some(signal) => view! {
|
||||
<input
|
||||
data-name="Input"
|
||||
type=type_str
|
||||
class=merged_class
|
||||
placeholder=placeholder
|
||||
name=name
|
||||
id=id
|
||||
title=title
|
||||
disabled=disabled
|
||||
readonly=readonly
|
||||
required=required
|
||||
autofocus=autofocus
|
||||
min=min
|
||||
max=max
|
||||
step=step
|
||||
bind:value=signal
|
||||
node_ref=node_ref
|
||||
/>
|
||||
}.into_any(),
|
||||
None => view! {
|
||||
<input
|
||||
data-name="Input"
|
||||
type=type_str
|
||||
class=merged_class
|
||||
placeholder=placeholder
|
||||
name=name
|
||||
id=id
|
||||
title=title
|
||||
disabled=disabled
|
||||
readonly=readonly
|
||||
required=required
|
||||
autofocus=autofocus
|
||||
min=min
|
||||
max=max
|
||||
step=step
|
||||
node_ref=node_ref
|
||||
/>
|
||||
}.into_any(),
|
||||
}
|
||||
}
|
||||
26
frontend/src/components/ui/mod.rs
Normal file
26
frontend/src/components/ui/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
pub mod accordion;
|
||||
pub mod alert_dialog;
|
||||
pub mod badge;
|
||||
pub mod button;
|
||||
pub mod button_action;
|
||||
pub mod card;
|
||||
pub mod checkbox;
|
||||
pub mod context_menu;
|
||||
pub mod data_table;
|
||||
pub mod dialog;
|
||||
pub mod dropdown_menu;
|
||||
pub mod empty;
|
||||
pub mod input;
|
||||
pub mod multi_select;
|
||||
pub mod select;
|
||||
pub mod separator;
|
||||
pub mod sheet;
|
||||
pub mod sidenav;
|
||||
pub mod skeleton;
|
||||
pub mod shimmer;
|
||||
pub mod svg_icon;
|
||||
pub mod switch;
|
||||
pub mod table;
|
||||
pub mod tabs;
|
||||
pub mod theme_toggle;
|
||||
pub mod toast;
|
||||
294
frontend/src/components/ui/multi_select.rs
Normal file
294
frontend/src/components/ui/multi_select.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use icons::{Check, ChevronDown, ChevronUp};
|
||||
use leptos::context::Provider;
|
||||
use leptos::prelude::*;
|
||||
use tw_merge::*;
|
||||
|
||||
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
|
||||
use crate::components::hooks::use_random::use_random_id_for;
|
||||
// * Reuse @select.rs
|
||||
pub use crate::components::ui::select::{
|
||||
SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem, SelectLabel as MultiSelectLabel,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum MultiSelectAlign {
|
||||
Start,
|
||||
#[default]
|
||||
Center,
|
||||
End,
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
#[component]
|
||||
pub fn MultiSelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
|
||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||
|
||||
view! {
|
||||
<span data-name="MultiSelectValue" class="text-sm text-muted-foreground truncate">
|
||||
{move || {
|
||||
let values = multi_select_ctx.values_signal.get();
|
||||
if values.is_empty() {
|
||||
placeholder.clone()
|
||||
} else {
|
||||
let count = values.len();
|
||||
if count == 1 { "1 selected".to_string() } else { format!("{} selected", count) }
|
||||
}
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MultiSelectOption(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional, into)] value: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||
|
||||
let value_clone = value.clone();
|
||||
let is_selected = Signal::derive(move || {
|
||||
if let Some(ref val) = value_clone {
|
||||
multi_select_ctx.values_signal.with(|values| values.contains(val))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
let class = tw_merge!(
|
||||
"group inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
class
|
||||
);
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
data-name="MultiSelectOption"
|
||||
class=class
|
||||
role="option"
|
||||
aria-selected=move || is_selected.get().to_string()
|
||||
on:click=move |ev: web_sys::MouseEvent| {
|
||||
ev.prevent_default();
|
||||
ev.stop_propagation();
|
||||
if let Some(val) = value.clone() {
|
||||
multi_select_ctx
|
||||
.values_signal
|
||||
.update(|values| {
|
||||
if values.contains(&val) {
|
||||
values.remove(&val);
|
||||
} else {
|
||||
values.insert(val);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
>
|
||||
{children()}
|
||||
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-selected:opacity-100" />
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MultiSelectContext {
|
||||
target_id: String,
|
||||
values_signal: RwSignal<HashSet<String>>,
|
||||
align: MultiSelectAlign,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MultiSelect(
|
||||
children: Children,
|
||||
#[prop(optional, into)] values: Option<RwSignal<HashSet<String>>>,
|
||||
#[prop(default = MultiSelectAlign::default())] align: MultiSelectAlign,
|
||||
) -> impl IntoView {
|
||||
let multi_select_target_id = use_random_id_for("multi_select");
|
||||
let values_signal = values.unwrap_or_else(|| RwSignal::new(HashSet::<String>::new()));
|
||||
|
||||
let multi_select_ctx = MultiSelectContext { target_id: multi_select_target_id.clone(), values_signal, align };
|
||||
|
||||
view! {
|
||||
<Provider value=multi_select_ctx>
|
||||
<div data-name="MultiSelect" class="relative w-fit">
|
||||
{children()}
|
||||
</div>
|
||||
</Provider>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MultiSelectTrigger(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional, into)] id: String,
|
||||
) -> impl IntoView {
|
||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||
|
||||
let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() };
|
||||
|
||||
let button_class = tw_merge!(
|
||||
"w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
|
||||
&peer_class,
|
||||
class
|
||||
);
|
||||
|
||||
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", multi_select_ctx.target_id) };
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
data-name="MultiSelectTrigger"
|
||||
class=button_class
|
||||
id=button_id
|
||||
tabindex="0"
|
||||
data-multi-select-trigger=multi_select_ctx.target_id
|
||||
>
|
||||
{children()}
|
||||
<ChevronDown class="text-muted-foreground" />
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MultiSelectContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||
|
||||
let align_str = match multi_select_ctx.align {
|
||||
MultiSelectAlign::Start => "start",
|
||||
MultiSelectAlign::Center => "center",
|
||||
MultiSelectAlign::End => "end",
|
||||
};
|
||||
|
||||
let class = tw_merge!(
|
||||
"w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[align=start]:left-0 data-[align=center]:left-1/2 data-[align=center]:-translate-x-1/2 data-[align=end]:right-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
class
|
||||
);
|
||||
|
||||
let target_id_for_script = multi_select_ctx.target_id.clone();
|
||||
let target_id_for_script_2 = multi_select_ctx.target_id.clone();
|
||||
|
||||
// Scroll indicator signals
|
||||
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
||||
|
||||
view! {
|
||||
<div
|
||||
data-name="MultiSelectContent"
|
||||
class=class
|
||||
id=multi_select_ctx.target_id
|
||||
data-target="target__multi_select"
|
||||
data-state="closed"
|
||||
data-align=align_str
|
||||
style="pointer-events: none;"
|
||||
on:scroll=move |ev| on_scroll.run(ev)
|
||||
>
|
||||
<div
|
||||
data-scroll-up="true"
|
||||
class=move || {
|
||||
let is_up: bool = can_scroll_up_signal.get();
|
||||
if is_up {
|
||||
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
}
|
||||
>
|
||||
<ChevronUp class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
{children()}
|
||||
<div
|
||||
data-scroll-down="true"
|
||||
class=move || {
|
||||
let is_down: bool = can_scroll_down_signal.get();
|
||||
if is_down {
|
||||
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
}
|
||||
>
|
||||
<ChevronDown class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{format!(
|
||||
r#"
|
||||
(function() {{
|
||||
const setupMultiSelect = () => {{
|
||||
const multiSelect = document.querySelector('#{}');
|
||||
const trigger = document.querySelector('[data-multi-select-trigger="{}"]');
|
||||
|
||||
if (!multiSelect || !trigger) {{
|
||||
setTimeout(setupMultiSelect, 50);
|
||||
return;
|
||||
}}
|
||||
|
||||
if (multiSelect.hasAttribute('data-initialized')) {{
|
||||
return;
|
||||
}}
|
||||
multiSelect.setAttribute('data-initialized', 'true');
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
const openMultiSelect = () => {{
|
||||
isOpen = true;
|
||||
if (window.ScrollLock) window.ScrollLock.lock();
|
||||
multiSelect.setAttribute('data-state', 'open');
|
||||
multiSelect.style.pointerEvents = 'auto';
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
multiSelect.style.minWidth = `${{triggerRect.width}}px`;
|
||||
multiSelect.dispatchEvent(new Event('scroll'));
|
||||
setTimeout(() => {{
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}}, 0);
|
||||
}};
|
||||
|
||||
const closeMultiSelect = () => {{
|
||||
isOpen = false;
|
||||
multiSelect.setAttribute('data-state', 'closed');
|
||||
multiSelect.style.pointerEvents = 'none';
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
if (window.ScrollLock) window.ScrollLock.unlock(200);
|
||||
}};
|
||||
|
||||
const handleClickOutside = (e) => {{
|
||||
if (!multiSelect.contains(e.target) && !trigger.contains(e.target)) {{
|
||||
closeMultiSelect();
|
||||
}}
|
||||
}};
|
||||
|
||||
trigger.addEventListener('click', (e) => {{
|
||||
e.stopPropagation();
|
||||
if (isOpen) closeMultiSelect(); else openMultiSelect();
|
||||
}});
|
||||
|
||||
document.addEventListener('keydown', (e) => {{
|
||||
if (e.key === 'Escape' && isOpen) {{
|
||||
e.preventDefault();
|
||||
closeMultiSelect();
|
||||
}}
|
||||
}});
|
||||
}};
|
||||
|
||||
if (document.readyState === 'loading') {{
|
||||
document.addEventListener('DOMContentLoaded', setupMultiSelect);
|
||||
}} else {{
|
||||
setupMultiSelect();
|
||||
}}
|
||||
}})();
|
||||
"#,
|
||||
target_id_for_script,
|
||||
target_id_for_script_2,
|
||||
)}
|
||||
</script>
|
||||
}.into_any()
|
||||
}
|
||||
311
frontend/src/components/ui/select.rs
Normal file
311
frontend/src/components/ui/select.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
use icons::{Check, ChevronDown, ChevronUp};
|
||||
use leptos::context::Provider;
|
||||
use leptos::prelude::*;
|
||||
use leptos_ui::clx;
|
||||
use strum::{AsRefStr, Display};
|
||||
use tw_merge::*;
|
||||
|
||||
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
|
||||
use crate::components::hooks::use_random::use_random_id_for;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, AsRefStr)]
|
||||
pub enum SelectPosition {
|
||||
#[default]
|
||||
Below,
|
||||
Above,
|
||||
}
|
||||
|
||||
mod components {
|
||||
use super::*;
|
||||
clx! {SelectLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
|
||||
clx! {SelectItem, li, "inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4"}
|
||||
}
|
||||
|
||||
pub use components::*;
|
||||
|
||||
#[component]
|
||||
pub fn SelectGroup(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = "Select options".into(), into)] aria_label: String,
|
||||
) -> impl IntoView {
|
||||
let merged_class = tw_merge!("group", class);
|
||||
|
||||
view! {
|
||||
<ul data-name="SelectGroup" role="listbox" aria-label=aria_label class=merged_class>
|
||||
{children()}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
|
||||
let select_ctx = expect_context::<SelectContext>();
|
||||
|
||||
view! {
|
||||
<span data-name="SelectValue" class="text-sm text-muted-foreground truncate">
|
||||
{move || { select_ctx.value_signal.get().unwrap_or_else(|| placeholder.clone()) }}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectOption(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = false.into(), into)] aria_selected: Signal<bool>,
|
||||
#[prop(optional, into)] value: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<SelectContext>();
|
||||
|
||||
let merged_class = tw_merge!(
|
||||
"group inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm cursor-pointer no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
class
|
||||
);
|
||||
|
||||
let value_for_check = value.clone();
|
||||
let is_selected = move || aria_selected.get() || ctx.value_signal.get() == value_for_check;
|
||||
|
||||
view! {
|
||||
<li
|
||||
data-name="SelectOption"
|
||||
class=merged_class
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected=move || is_selected().to_string()
|
||||
data-select-option="true"
|
||||
on:click=move |_| {
|
||||
let val = value.clone();
|
||||
ctx.value_signal.set(val.clone());
|
||||
if let Some(on_change) = ctx.on_change {
|
||||
on_change.run(val);
|
||||
}
|
||||
}
|
||||
>
|
||||
{children()}
|
||||
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-selected:opacity-100" />
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SelectContext {
|
||||
target_id: String,
|
||||
value_signal: RwSignal<Option<String>>,
|
||||
on_change: Option<Callback<Option<String>>>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Select(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional, into)] default_value: Option<String>,
|
||||
#[prop(optional)] on_change: Option<Callback<Option<String>>>,
|
||||
) -> impl IntoView {
|
||||
let select_target_id = use_random_id_for("select");
|
||||
let value_signal = RwSignal::new(default_value);
|
||||
|
||||
let ctx = SelectContext { target_id: select_target_id.clone(), value_signal, on_change };
|
||||
|
||||
let merged_class = tw_merge!("relative w-fit", class);
|
||||
|
||||
view! {
|
||||
<Provider value=ctx>
|
||||
<div data-name="Select" class=merged_class>
|
||||
{children()}
|
||||
</div>
|
||||
</Provider>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectTrigger(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional, into)] id: String,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<SelectContext>();
|
||||
|
||||
let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() };
|
||||
|
||||
let button_class = tw_merge!(
|
||||
"w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
|
||||
&peer_class,
|
||||
class
|
||||
);
|
||||
|
||||
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", ctx.target_id) };
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
data-name="SelectTrigger"
|
||||
class=button_class
|
||||
id=button_id
|
||||
tabindex="0"
|
||||
data-select-trigger=ctx.target_id
|
||||
>
|
||||
{children()}
|
||||
<ChevronDown class="text-muted-foreground" />
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectContent(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(default = SelectPosition::default())] position: SelectPosition,
|
||||
#[prop(optional)] on_close: Option<Callback<()>>,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<SelectContext>();
|
||||
|
||||
let merged_class = tw_merge!(
|
||||
"w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] left-0 data-[position=Above]:top-auto data-[position=Above]:bottom-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[state=closed]:data-[position=Below]:origin-top data-[state=open]:data-[position=Below]:origin-top data-[state=closed]:data-[position=Above]:origin-bottom data-[state=open]:data-[position=Above]:origin-bottom [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
class
|
||||
);
|
||||
|
||||
let target_id_for_script = ctx.target_id.clone();
|
||||
let target_id_for_script_2 = ctx.target_id.clone();
|
||||
|
||||
// Scroll indicator signals
|
||||
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
||||
|
||||
view! {
|
||||
<div
|
||||
data-name="SelectContent"
|
||||
class=merged_class
|
||||
on:selectclose=move |_: web_sys::CustomEvent| {
|
||||
if let Some(cb) = on_close {
|
||||
cb.run(());
|
||||
}
|
||||
}
|
||||
id=ctx.target_id
|
||||
data-target="target__select"
|
||||
data-state="closed"
|
||||
data-position=position.to_string()
|
||||
style="pointer-events: none;"
|
||||
on:scroll=move |ev| on_scroll.run(ev)
|
||||
>
|
||||
<div
|
||||
data-scroll-up="true"
|
||||
class=move || {
|
||||
let is_up: bool = can_scroll_up_signal.get();
|
||||
if is_up {
|
||||
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
}
|
||||
>
|
||||
<ChevronUp class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
{children()}
|
||||
<div
|
||||
data-scroll-down="true"
|
||||
class=move || {
|
||||
let is_down: bool = can_scroll_down_signal.get();
|
||||
if is_down {
|
||||
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
}
|
||||
>
|
||||
<ChevronDown class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{format!(
|
||||
r#"
|
||||
(function() {{
|
||||
const setupSelect = () => {{
|
||||
const select = document.querySelector('#{}');
|
||||
const trigger = document.querySelector('[data-select-trigger="{}"]');
|
||||
|
||||
if (!select || !trigger) {{
|
||||
setTimeout(setupSelect, 50);
|
||||
return;
|
||||
}}
|
||||
|
||||
if (select.hasAttribute('data-initialized')) {{
|
||||
return;
|
||||
}}
|
||||
select.setAttribute('data-initialized', 'true');
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
const updatePosition = () => {{
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom;
|
||||
const spaceAbove = triggerRect.top;
|
||||
|
||||
if (spaceBelow < 200 && spaceAbove > spaceBelow) {{
|
||||
select.setAttribute('data-position', 'Above');
|
||||
}} else {{
|
||||
select.setAttribute('data-position', 'Below');
|
||||
}}
|
||||
|
||||
select.style.minWidth = `${{triggerRect.width}}px`;
|
||||
}};
|
||||
|
||||
const openSelect = () => {{
|
||||
isOpen = true;
|
||||
if (window.ScrollLock) window.ScrollLock.lock();
|
||||
updatePosition();
|
||||
select.setAttribute('data-state', 'open');
|
||||
select.style.pointerEvents = 'auto';
|
||||
select.dispatchEvent(new Event('scroll'));
|
||||
setTimeout(() => {{
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}}, 0);
|
||||
}};
|
||||
|
||||
const closeSelect = () => {{
|
||||
isOpen = false;
|
||||
select.setAttribute('data-state', 'closed');
|
||||
select.style.pointerEvents = 'none';
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
select.dispatchEvent(new CustomEvent('selectclose', {{ bubbles: false }}));
|
||||
if (window.ScrollLock) window.ScrollLock.unlock(200);
|
||||
}};
|
||||
|
||||
const handleClickOutside = (e) => {{
|
||||
if (!select.contains(e.target) && !trigger.contains(e.target)) {{
|
||||
closeSelect();
|
||||
}}
|
||||
}};
|
||||
|
||||
trigger.addEventListener('click', (e) => {{
|
||||
e.stopPropagation();
|
||||
if (isOpen) closeSelect(); else openSelect();
|
||||
}});
|
||||
|
||||
const options = select.querySelectorAll('[data-select-option]');
|
||||
options.forEach(option => {{
|
||||
option.addEventListener('click', () => closeSelect());
|
||||
}});
|
||||
|
||||
document.addEventListener('keydown', (e) => {{
|
||||
if (e.key === 'Escape' && isOpen) {{
|
||||
e.preventDefault();
|
||||
closeSelect();
|
||||
}}
|
||||
}});
|
||||
}};
|
||||
|
||||
if (document.readyState === 'loading') {{
|
||||
document.addEventListener('DOMContentLoaded', setupSelect);
|
||||
}} else {{
|
||||
setupSelect();
|
||||
}}
|
||||
}})();
|
||||
"#,
|
||||
target_id_for_script,
|
||||
target_id_for_script_2,
|
||||
)}
|
||||
</script>
|
||||
}.into_any()
|
||||
}
|
||||
35
frontend/src/components/ui/separator.rs
Normal file
35
frontend/src/components/ui/separator.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use leptos::prelude::*;
|
||||
use tw_merge::*;
|
||||
|
||||
#[component]
|
||||
pub fn Separator(
|
||||
#[prop(into, optional)] orientation: Signal<SeparatorOrientation>,
|
||||
#[prop(into, optional)] class: String,
|
||||
// children: Children,
|
||||
) -> impl IntoView {
|
||||
let merged_class = Memo::new(move |_| {
|
||||
let orientation = orientation.get();
|
||||
let separator = SeparatorClass { orientation };
|
||||
separator.with_class(class.clone())
|
||||
});
|
||||
|
||||
view! { <div class=merged_class role="separator" /> }
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* 🧬 STRUCT 🧬 */
|
||||
/* ========================================================== */
|
||||
|
||||
#[derive(TwClass, Default)]
|
||||
#[tw(class = "shrink-0 bg-border")]
|
||||
pub struct SeparatorClass {
|
||||
orientation: SeparatorOrientation,
|
||||
}
|
||||
|
||||
#[derive(TwVariant)]
|
||||
pub enum SeparatorOrientation {
|
||||
#[tw(default, class = "w-full h-[1px]")]
|
||||
Default,
|
||||
#[tw(class = "h-full w-[1px]")]
|
||||
Vertical,
|
||||
}
|
||||
237
frontend/src/components/ui/sheet.rs
Normal file
237
frontend/src/components/ui/sheet.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
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"}
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ 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]",
|
||||
}
|
||||
}
|
||||
}
|
||||
52
frontend/src/components/ui/shimmer.rs
Normal file
52
frontend/src/components/ui/shimmer.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use leptos::prelude::*;
|
||||
use tw_merge::*;
|
||||
|
||||
use crate::components::hooks::use_random::use_random_id_for;
|
||||
|
||||
#[component]
|
||||
pub fn Shimmer(
|
||||
/// Controls shimmer visibility (works with any bool signal)
|
||||
#[prop(into)]
|
||||
loading: Signal<bool>,
|
||||
|
||||
/// Color of the shimmer wave (default: "rgba(255,255,255,0.15)")
|
||||
#[prop(into, optional)]
|
||||
shimmer_color: Option<String>,
|
||||
|
||||
/// Background color of shimmer blocks (default: "rgba(255,255,255,0.08)")
|
||||
#[prop(into, optional)]
|
||||
background_color: Option<String>,
|
||||
|
||||
/// Animation duration in seconds (default: 1.5)
|
||||
#[prop(optional)]
|
||||
duration: Option<f64>,
|
||||
|
||||
/// Fallback border-radius for text elements in px (default: 4)
|
||||
#[prop(optional)]
|
||||
fallback_border_radius: Option<f64>,
|
||||
|
||||
/// Additional classes
|
||||
#[prop(into, optional)]
|
||||
class: String,
|
||||
|
||||
/// Children to wrap
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
let shimmer_id = use_random_id_for("Shimmer");
|
||||
let merged_class = tw_merge!("relative", class);
|
||||
|
||||
view! {
|
||||
<div
|
||||
id=shimmer_id
|
||||
class=merged_class
|
||||
data-name="Shimmer"
|
||||
data-shimmer-loading=move || loading.get().to_string()
|
||||
data-shimmer-color=shimmer_color
|
||||
data-shimmer-bg-color=background_color
|
||||
data-shimmer-duration=duration.map(|d| d.to_string())
|
||||
data-shimmer-fallback-radius=fallback_border_radius.map(|r| r.to_string())
|
||||
>
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
232
frontend/src/components/ui/sidenav.rs
Normal file
232
frontend/src/components/ui/sidenav.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_location;
|
||||
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 md:peer-data-[variant=Inset]:m-2 md:peer-data-[variant=Inset]:ml-0 md:peer-data-[variant=Inset]:rounded-xl md:peer-data-[variant=Inset]:shadow-sm md:peer-data-[variant=Inset]:peer-data-[state=Collapsed]:ml-2"}
|
||||
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"}
|
||||
// * data-[], not group-data-[]
|
||||
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"}
|
||||
// Button "More"
|
||||
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", // TODO. Port tw_merge to Tailwind V4.
|
||||
// "focus-visible:ring-[3px]", // TODO. Port tw_merge to Tailwind V4.
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"read-only:bg-muted",
|
||||
// Specific to Sidenav
|
||||
"w-full h-8 shadow-none bg-background"
|
||||
}
|
||||
}
|
||||
|
||||
pub use components::*;
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
#[component]
|
||||
pub fn SidenavLink(
|
||||
children: Children,
|
||||
#[prop(into)] href: String,
|
||||
#[prop(optional, into)] class: String,
|
||||
) -> impl IntoView {
|
||||
let merged_class = tw_merge!(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left outline-hidden ring-sidenav-ring transition-[width,height,padding] 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-semibold aria-[current=page]:text-sidenav-accent-foreground data-[state=open]:hover:bg-sidenav-accent data-[state=open]:hover:text-sidenav-accent-foreground group-data-[collapsible=Icon]:size-8! group-data-[collapsible=Icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 hover:bg-sidenav-accent hover:text-sidenav-accent-foreground h-8 text-sm",
|
||||
class
|
||||
);
|
||||
|
||||
let location = use_location();
|
||||
|
||||
// Check if the link is active based on current path
|
||||
let href_clone = href.clone();
|
||||
let is_active = move || {
|
||||
let path = location.pathname.get();
|
||||
path == href_clone || path.starts_with(&format!("{}/", href_clone))
|
||||
};
|
||||
|
||||
let aria_current = move || if is_active() { "page" } else { "false" };
|
||||
|
||||
view! {
|
||||
<a data-name="SidenavLink" class=merged_class href=href aria-current=aria_current>
|
||||
{children()}
|
||||
</a>
|
||||
}
|
||||
}
|
||||
|
||||
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-[collapsible=Icon]:size-8! group-data-[collapsible=Icon]:p-0! [&>svg]:stroke-2 aria-[current=page]:[&>svg]:stroke-[2.7]",
|
||||
variants: {
|
||||
variant: {
|
||||
Default: "hover:bg-sidenav-accent hover:text-sidenav-accent-foreground", // Already in base
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Sidenav(
|
||||
#[prop(into, optional)] class: String,
|
||||
#[prop(default = SidenavVariant::default())] variant: SidenavVariant,
|
||||
#[prop(default = SidenavState::default())] data_state: SidenavState,
|
||||
#[prop(default = SidenavSide::default())] data_side: SidenavSide,
|
||||
#[prop(default = SidenavCollapsible::default())] data_collapsible: SidenavCollapsible,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
{if data_collapsible == SidenavCollapsible::None {
|
||||
view! {
|
||||
<aside
|
||||
data-name="Sidenav"
|
||||
class=tw_merge!(
|
||||
"flex flex-col h-full bg-sidenav text-sidenav-foreground w-(--sidenav-width)", class.clone()
|
||||
)
|
||||
>
|
||||
{children()}
|
||||
</aside>
|
||||
}
|
||||
.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<aside
|
||||
data-name="Sidenav"
|
||||
data-sidenav=data_state.to_string()
|
||||
data-side=data_side.to_string()
|
||||
class="hidden md:block group peer text-sidenav-foreground data-[state=Collapsed]:hidden"
|
||||
>
|
||||
// * SidenavGap: This is what handles the sidenav gap on desktop
|
||||
<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-[side=Right]:rotate-180",
|
||||
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)]"
|
||||
},
|
||||
match variant {
|
||||
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:w-(--sidenav-width-icon) group-data-[side=Left]:border-r group-data-[side=Right]:border-l",
|
||||
SidenavVariant::Floating | SidenavVariant::Inset =>
|
||||
"p-2 group-data-[collapsible=Icon]:w-[calc(var(--sidenav-width-icon)+(--spacing(4))+2px)]",
|
||||
},
|
||||
)
|
||||
>
|
||||
// * Act as a Sidenav for the onclick trigger to work with nested Sidenavs.
|
||||
<SidenavInner attr:data-sidenav="Sidenav" attr:data-variant=variant.to_string()>
|
||||
{children()}
|
||||
<SidenavToggleRail />
|
||||
</SidenavInner>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
.into_any()
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* ✨ FUNCTIONS ✨ */
|
||||
/* ========================================================== */
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
|
||||
pub enum SidenavState {
|
||||
#[default]
|
||||
Expanded,
|
||||
Collapsed,
|
||||
}
|
||||
|
||||
const ONCLICK_TRIGGER: &str = "document.querySelector('[data-name=\"Sidenav\"]').setAttribute('data-state', document.querySelector('[data-name=\"Sidenav\"]').getAttribute('data-state') === 'Collapsed' ? 'Expanded' : 'Collapsed')";
|
||||
|
||||
#[component]
|
||||
pub fn SidenavTrigger(children: Children) -> impl IntoView {
|
||||
view! {
|
||||
// TODO. Use Button.
|
||||
|
||||
<button
|
||||
onclick=ONCLICK_TRIGGER
|
||||
data-name="SidenavTrigger"
|
||||
class="inline-flex gap-2 justify-center items-center -ml-1 text-sm font-medium whitespace-nowrap rounded-md transition-all outline-none disabled:opacity-50 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 aria-invalid:ring-destructive/20 aria-invalid:border-destructive size-7 dark:aria-invalid:ring-destructive/40 dark:hover:bg-accent/50 hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||
>
|
||||
{children()}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SidenavToggleRail() -> impl IntoView {
|
||||
view! {
|
||||
<button
|
||||
data-name="SidenavToggleRail"
|
||||
aria-label="Toggle Sidenav"
|
||||
tabindex="-1"
|
||||
onclick=ONCLICK_TRIGGER
|
||||
class="hidden absolute inset-y-0 z-20 w-4 transition-all ease-linear -translate-x-1/2 sm:flex group-data-[side=Left]:-right-4 group-data-[side=Right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] in-data-[side=Left]:cursor-w-resize in-data-[side=Right]:cursor-e-resize [[data-side=Left][data-state=Collapsed]_&]:cursor-e-resize [[data-side=Right][data-state=Collapsed]_&]:cursor-w-resize group-data-[collapsible=Offcanvas]:translate-x-0 group-data-[collapsible=Offcanvas]:after:left-full [[data-side=Left][data-collapsible=Offcanvas]_&]:-right-2 [[data-side=Right][data-collapsible=Offcanvas]_&]:-left-0eft-2 hover:after:bg-sidenav-border hover:group-data-[collapsible=Offcanvas]:bg-sidenav"
|
||||
/>
|
||||
}
|
||||
}
|
||||
13
frontend/src/components/ui/skeleton.rs
Normal file
13
frontend/src/components/ui/skeleton.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
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 /> }
|
||||
}
|
||||
25
frontend/src/components/ui/svg_icon.rs
Normal file
25
frontend/src/components/ui/svg_icon.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use leptos::prelude::*;
|
||||
use tw_merge::tw_merge;
|
||||
|
||||
#[component]
|
||||
pub fn SvgIcon(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
) -> impl IntoView {
|
||||
let class = tw_merge!("size-4", class);
|
||||
|
||||
view! {
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class=class
|
||||
>
|
||||
{children()}
|
||||
</svg>
|
||||
}
|
||||
}
|
||||
42
frontend/src/components/ui/switch.rs
Normal file
42
frontend/src/components/ui/switch.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
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>
|
||||
}
|
||||
}
|
||||
60
frontend/src/components/ui/table.rs
Normal file
60
frontend/src/components/ui/table.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
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> }
|
||||
}
|
||||
108
frontend/src/components/ui/tabs.rs
Normal file
108
frontend/src/components/ui/tabs.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use leptos::context::Provider;
|
||||
use leptos::prelude::*;
|
||||
use tw_merge::tw_merge;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TabsContext {
|
||||
pub active_tab: RwSignal<String>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Tabs(
|
||||
#[prop(into)] default_value: String,
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
) -> impl IntoView {
|
||||
let active_tab = RwSignal::new(default_value);
|
||||
let ctx = TabsContext { active_tab };
|
||||
|
||||
let merged_class = tw_merge!("w-full", &class);
|
||||
|
||||
view! {
|
||||
<Provider value=ctx>
|
||||
<div data-name="Tabs" class=merged_class>
|
||||
{children()}
|
||||
</div>
|
||||
</Provider>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TabsList(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
) -> impl IntoView {
|
||||
let merged_class = tw_merge!(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
&class
|
||||
);
|
||||
|
||||
view! {
|
||||
<div data-name="TabsList" class=merged_class>
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TabsTrigger(
|
||||
#[prop(into)] value: String,
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<TabsContext>();
|
||||
let v_clone = value.clone();
|
||||
|
||||
let is_active = Memo::new(move |_| ctx.active_tab.get() == v_clone);
|
||||
|
||||
let merged_class = move || tw_merge!(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none",
|
||||
if is_active.get() {
|
||||
"bg-background text-foreground shadow-sm"
|
||||
} else {
|
||||
"hover:bg-background/50 hover:text-foreground"
|
||||
},
|
||||
&class
|
||||
);
|
||||
|
||||
view! {
|
||||
<button
|
||||
data-name="TabsTrigger"
|
||||
type="button"
|
||||
class=merged_class
|
||||
data-state=move || if is_active.get() { "active" } else { "inactive" }
|
||||
on:click=move |_| ctx.active_tab.set(value.clone())
|
||||
>
|
||||
{children()}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TabsContent(
|
||||
#[prop(into)] value: String,
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<TabsContext>();
|
||||
let v_clone = value.clone();
|
||||
|
||||
let is_active = Memo::new(move |_| ctx.active_tab.get() == v_clone);
|
||||
|
||||
let merged_class = move || tw_merge!(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
if !is_active.get() { "hidden" } else { "" },
|
||||
&class
|
||||
);
|
||||
|
||||
view! {
|
||||
<div
|
||||
data-name="TabsContent"
|
||||
class=merged_class
|
||||
data-state=move || if is_active.get() { "active" } else { "inactive" }
|
||||
tabindex=move || if is_active.get() { "0" } else { "-1" }
|
||||
>
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
76
frontend/src/components/ui/theme_toggle.rs
Normal file
76
frontend/src/components/ui/theme_toggle.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use crate::components::ui::svg_icon::SvgIcon;
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::components::hooks::use_theme_mode::use_theme_mode;
|
||||
|
||||
#[component]
|
||||
pub fn ThemeToggle() -> impl IntoView {
|
||||
let theme_mode = use_theme_mode();
|
||||
|
||||
view! {
|
||||
<style>
|
||||
{"
|
||||
.theme__toggle_transition {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
svg path {
|
||||
transform-origin: center;
|
||||
transition: all .6s ease;
|
||||
transform: translate3d(0,0,0);
|
||||
backface-visibility: hidden;
|
||||
|
||||
&.sun {
|
||||
transform: scale(.4) rotate(60deg);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.moon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.switch {
|
||||
svg path {
|
||||
&.sun {
|
||||
transform: scale(1) rotate(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.moon {
|
||||
transform: scale(.4) rotate(-60deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"}
|
||||
</style>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle theme"
|
||||
class=move || {
|
||||
let base_class = "theme__toggle_transition";
|
||||
if theme_mode.get() { format!("{base_class} switch") } else { base_class.to_string() }
|
||||
}
|
||||
on:click=move |_| theme_mode.toggle()
|
||||
>
|
||||
<SvgIcon class="size-4">
|
||||
<path
|
||||
d="M12 1.75V3.25M12 20.75V22.25M1.75 12H3.25M20.75 12H22.25M4.75216 4.75216L5.81282 5.81282M18.1872 18.1872L19.2478 19.2478M4.75216 19.2478L5.81282 18.1872M18.1872 5.81282L19.2478 4.75216M16.25 12C16.25 14.3472 14.3472 16.25 12 16.25C9.65279 16.25 7.75 14.3472 7.75 12C7.75 9.65279 9.65279 7.75 12 7.75C14.3472 7.75 16.25 9.65279 16.25 12Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
class="sun text-neutral-300"
|
||||
/>
|
||||
<path
|
||||
d="M2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C16.7154 21.25 20.6068 17.7216 21.1778 13.161C20.1198 13.8498 18.8566 14.25 17.5 14.25C13.7721 14.25 10.75 11.2279 10.75 7.5C10.75 5.66012 11.4861 3.99217 12.6799 2.77461C12.4554 2.7583 12.2287 2.75 12 2.75C6.89137 2.75 2.75 6.89137 2.75 12Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
class="moon text-neutral-700"
|
||||
/>
|
||||
</SvgIcon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
251
frontend/src/components/ui/toast.rs
Normal file
251
frontend/src/components/ui/toast.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
use leptos::prelude::*;
|
||||
use tw_merge::*;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ToastType {
|
||||
#[default]
|
||||
Default,
|
||||
Success,
|
||||
Error,
|
||||
Warning,
|
||||
Info,
|
||||
Loading,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum SonnerPosition {
|
||||
TopLeft,
|
||||
TopCenter,
|
||||
TopRight,
|
||||
#[default]
|
||||
BottomRight,
|
||||
BottomCenter,
|
||||
BottomLeft,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ToastData {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub variant: ToastType,
|
||||
pub duration: u64, // ms
|
||||
pub is_exiting: RwSignal<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ToasterStore {
|
||||
pub toasts: RwSignal<Vec<ToastData>>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SonnerTrigger(
|
||||
toast: ToastData,
|
||||
index: usize,
|
||||
total: usize,
|
||||
position: SonnerPosition,
|
||||
is_expanded: Signal<bool>,
|
||||
#[prop(optional)] on_dismiss: Option<Callback<()>>,
|
||||
) -> impl IntoView {
|
||||
let _ = is_expanded; // Silence unused warning while keeping prop name intact for builder
|
||||
let variant_classes = match toast.variant {
|
||||
ToastType::Default => "bg-background text-foreground border-border",
|
||||
ToastType::Success => "bg-background text-foreground border-border [&_.icon]:text-green-500",
|
||||
ToastType::Error => "bg-background text-foreground border-border [&_.icon]:text-destructive",
|
||||
ToastType::Warning => "bg-background text-foreground border-border [&_.icon]:text-yellow-500",
|
||||
ToastType::Info => "bg-background text-foreground border-border [&_.icon]:text-blue-500",
|
||||
ToastType::Loading => "bg-background text-foreground border-border",
|
||||
};
|
||||
|
||||
let bar_color = match toast.variant {
|
||||
ToastType::Success => "bg-green-500",
|
||||
ToastType::Error => "bg-destructive",
|
||||
ToastType::Warning => "bg-yellow-500",
|
||||
ToastType::Info => "bg-blue-500",
|
||||
_ => "bg-primary",
|
||||
};
|
||||
|
||||
// Simplified Style (No manual translateY needed with Flexbox)
|
||||
let style = move || {
|
||||
format!(
|
||||
"z-index: {}; opacity: 1; transition: all 0.3s ease;",
|
||||
total - index
|
||||
)
|
||||
};
|
||||
|
||||
let animation_class = move || {
|
||||
let pos = position.to_string();
|
||||
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! {
|
||||
<div
|
||||
class=move || tw_merge!(
|
||||
"relative transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto",
|
||||
"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",
|
||||
variant_classes,
|
||||
animation_class()
|
||||
)
|
||||
style=style
|
||||
on:click=move |_| {
|
||||
if let Some(cb) = on_dismiss {
|
||||
cb.run(());
|
||||
}
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
<div class="flex flex-col gap-0.5 overflow-hidden flex-1">
|
||||
<div class="text-sm font-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>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static TOASTS: std::cell::RefCell<Option<RwSignal<Vec<ToastData>>>> = std::cell::RefCell::new(None);
|
||||
}
|
||||
|
||||
pub fn provide_toaster() {
|
||||
let toasts = RwSignal::new(Vec::<ToastData>::new());
|
||||
TOASTS.with(|t| *t.borrow_mut() = Some(toasts));
|
||||
provide_context(ToasterStore { toasts });
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
|
||||
let store = use_context::<ToasterStore>().expect("Toaster context not found");
|
||||
let toasts = store.toasts;
|
||||
|
||||
let is_bottom = position.to_string().contains("Bottom");
|
||||
|
||||
let container_class = match position {
|
||||
SonnerPosition::TopLeft => "left-6 top-6 items-start",
|
||||
SonnerPosition::TopRight => "right-6 top-6 items-end",
|
||||
SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-6 items-center",
|
||||
SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-6 items-center",
|
||||
SonnerPosition::BottomLeft => "left-6 bottom-6 items-start",
|
||||
SonnerPosition::BottomRight => "right-6 bottom-6 items-end",
|
||||
};
|
||||
|
||||
view! {
|
||||
<style>
|
||||
"@keyframes sonner-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||
@keyframes sonner-in-right { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
@keyframes sonner-out-right { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
|
||||
@keyframes sonner-in-left { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
@keyframes sonner-out-left { from { transform: translateX(0); opacity: 1; } to { transform: translateX(-100%); opacity: 0; } }
|
||||
.animate-sonner-in-right { animation: sonner-in-right 0.3s ease-out forwards; }
|
||||
.animate-sonner-out-right { animation: sonner-out-right 0.3s ease-in forwards; }
|
||||
.animate-sonner-in-left { animation: sonner-in-left 0.3s ease-out forwards; }
|
||||
.animate-sonner-out-left { animation: sonner-out-left 0.3s ease-in forwards; }"
|
||||
</style>
|
||||
<div
|
||||
class=tw_merge!(
|
||||
"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));
|
||||
});
|
||||
})
|
||||
/>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
pub fn toast(title: impl Into<String>, variant: ToastType) {
|
||||
let signal_opt = TOASTS.with(|t| *t.borrow());
|
||||
|
||||
if let Some(toasts) = signal_opt {
|
||||
let id = js_sys::Math::random().to_bits();
|
||||
let new_toast = ToastData {
|
||||
id,
|
||||
title: title.into(),
|
||||
description: None,
|
||||
variant,
|
||||
duration: 4000,
|
||||
is_exiting: RwSignal::new(false),
|
||||
};
|
||||
|
||||
toasts.update(|t| {
|
||||
t.push(new_toast.clone());
|
||||
if t.len() > 5 {
|
||||
t.remove(0);
|
||||
}
|
||||
});
|
||||
|
||||
let duration = new_toast.duration;
|
||||
let is_exiting = new_toast.is_exiting;
|
||||
leptos::task::spawn_local(async move {
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn toast_success(title: impl Into<String>) { toast(title, ToastType::Success); }
|
||||
#[allow(dead_code)]
|
||||
pub fn toast_error(title: impl Into<String>) { toast(title, ToastType::Error); }
|
||||
#[allow(dead_code)]
|
||||
pub fn toast_warning(title: impl Into<String>) { toast(title, ToastType::Warning); }
|
||||
#[allow(dead_code)]
|
||||
pub fn toast_info(title: impl Into<String>) { toast(title, ToastType::Info); }
|
||||
@@ -1,21 +1,27 @@
|
||||
#![recursion_limit = "256"]
|
||||
mod app;
|
||||
// mod models; // Removed
|
||||
mod components;
|
||||
pub mod utils;
|
||||
pub mod store;
|
||||
pub mod api;
|
||||
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos::mount::mount_to_body;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use app::App;
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
console_log::init_with_level(log::Level::Debug).unwrap();
|
||||
console_log::init_with_level(log::Level::Debug)
|
||||
.expect("Failed to initialize logging");
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let document = window.document().unwrap();
|
||||
let body = document.body().unwrap();
|
||||
let window = web_sys::window()
|
||||
.expect("Failed to access window - browser may not be fully loaded");
|
||||
let document = window.document()
|
||||
.expect("Failed to access document");
|
||||
let body = document.body()
|
||||
.expect("Failed to access document body");
|
||||
|
||||
// Add app-loaded class to body to hide spinner via CSS
|
||||
let _ = body.class_list().add_1("app-loaded");
|
||||
|
||||
@@ -1,79 +1,33 @@
|
||||
use futures::StreamExt;
|
||||
use gloo_net::eventsource::futures::EventSource;
|
||||
use leptos::*;
|
||||
use shared::{AppEvent, GlobalStats, NotificationLevel, SystemNotification, Torrent};
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
|
||||
use std::collections::HashMap;
|
||||
use struct_patch::traits::Patch;
|
||||
use base64::{Engine as _, engine::general_purpose::{URL_SAFE_NO_PAD as BASE64_URL, STANDARD as BASE64}};
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NotificationItem {
|
||||
pub id: u64,
|
||||
pub notification: SystemNotification,
|
||||
}
|
||||
use crate::components::ui::toast::{ToastType, toast};
|
||||
|
||||
// ============================================================================
|
||||
// Toast Helper Functions (Clean Code: Single Responsibility)
|
||||
// ============================================================================
|
||||
|
||||
/// Shows a toast notification using a direct signal reference.
|
||||
/// Use this version inside async blocks (spawn_local) where use_context is unavailable.
|
||||
/// Auto-removes after 5 seconds.
|
||||
pub fn show_toast_with_signal(
|
||||
notifications: RwSignal<Vec<NotificationItem>>,
|
||||
level: NotificationLevel,
|
||||
message: impl Into<String>,
|
||||
) {
|
||||
let id = js_sys::Date::now() as u64;
|
||||
let notification = SystemNotification {
|
||||
level,
|
||||
message: message.into(),
|
||||
};
|
||||
let item = NotificationItem { id, notification };
|
||||
|
||||
notifications.update(|list| list.push(item));
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
let _ = set_timeout(
|
||||
move || {
|
||||
notifications.update(|list| list.retain(|i| i.id != id));
|
||||
},
|
||||
std::time::Duration::from_secs(5),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a toast notification with the given level and message.
|
||||
/// Only works within reactive scope (components, effects). For async, use show_toast_with_signal.
|
||||
/// Auto-removes after 5 seconds.
|
||||
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
|
||||
if let Some(store) = use_context::<TorrentStore>() {
|
||||
show_toast_with_signal(store.notifications, level, message);
|
||||
}
|
||||
let msg = message.into();
|
||||
gloo_console::log!("TOAST CALL:", &msg, format!("{:?}", level));
|
||||
log::info!("Displaying toast: [{:?}] {}", level, msg);
|
||||
|
||||
let variant = match level {
|
||||
NotificationLevel::Success => ToastType::Success,
|
||||
NotificationLevel::Error => ToastType::Error,
|
||||
NotificationLevel::Warning => ToastType::Warning,
|
||||
NotificationLevel::Info => ToastType::Info,
|
||||
};
|
||||
|
||||
toast(msg, variant);
|
||||
}
|
||||
|
||||
/// Convenience function for success toasts (reactive scope only)
|
||||
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); }
|
||||
|
||||
/// Convenience function for error toasts (reactive scope only)
|
||||
pub fn toast_error(message: impl Into<String>) {
|
||||
show_toast(NotificationLevel::Error, message);
|
||||
}
|
||||
|
||||
/// Convenience function for info toasts (reactive scope only)
|
||||
pub fn toast_info(message: impl Into<String>) {
|
||||
show_toast(NotificationLevel::Info, message);
|
||||
}
|
||||
|
||||
/// Convenience function for warning toasts (reactive scope only)
|
||||
pub fn toast_warning(message: impl Into<String>) {
|
||||
show_toast(NotificationLevel::Warning, message);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Action Message Mapping (Clean Code: DRY Principle)
|
||||
// ============================================================================
|
||||
|
||||
/// Maps torrent action strings to user-friendly Turkish messages.
|
||||
/// Returns (success_message, error_message)
|
||||
pub fn get_action_messages(action: &str) -> (&'static str, &'static str) {
|
||||
match action {
|
||||
"start" => ("Torrent başlatıldı", "Torrent başlatılamadı"),
|
||||
@@ -88,520 +42,208 @@ pub fn get_action_messages(action: &str) -> (&'static str, &'static str) {
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FilterStatus {
|
||||
All,
|
||||
Downloading,
|
||||
Seeding,
|
||||
Completed,
|
||||
Paused,
|
||||
Inactive,
|
||||
Active,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl FilterStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
FilterStatus::All => "All",
|
||||
FilterStatus::Downloading => "Downloading",
|
||||
FilterStatus::Seeding => "Seeding",
|
||||
FilterStatus::Completed => "Completed",
|
||||
FilterStatus::Paused => "Paused",
|
||||
FilterStatus::Inactive => "Inactive",
|
||||
FilterStatus::Active => "Active",
|
||||
FilterStatus::Error => "Error",
|
||||
}
|
||||
}
|
||||
All, Downloading, Seeding, Completed, Paused, Inactive, Active, Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TorrentStore {
|
||||
pub torrents: RwSignal<Vec<Torrent>>,
|
||||
pub torrents: RwSignal<HashMap<String, Torrent>>,
|
||||
pub filter: RwSignal<FilterStatus>,
|
||||
pub search_query: RwSignal<String>,
|
||||
pub global_stats: RwSignal<GlobalStats>,
|
||||
pub notifications: RwSignal<Vec<NotificationItem>>,
|
||||
pub user: RwSignal<Option<String>>,
|
||||
pub selected_torrent: RwSignal<Option<String>>,
|
||||
pub push_enabled: RwSignal<bool>,
|
||||
}
|
||||
|
||||
pub fn provide_torrent_store() {
|
||||
let torrents = create_rw_signal(Vec::<Torrent>::new());
|
||||
let filter = create_rw_signal(FilterStatus::All);
|
||||
let search_query = create_rw_signal(String::new());
|
||||
let global_stats = create_rw_signal(GlobalStats::default());
|
||||
let notifications = create_rw_signal(Vec::<NotificationItem>::new());
|
||||
let torrents = RwSignal::new(HashMap::new());
|
||||
let filter = RwSignal::new(FilterStatus::All);
|
||||
let search_query = RwSignal::new(String::new());
|
||||
let global_stats = RwSignal::new(GlobalStats::default());
|
||||
let user = RwSignal::new(Option::<String>::None);
|
||||
let selected_torrent = RwSignal::new(Option::<String>::None);
|
||||
let push_enabled = RwSignal::new(false);
|
||||
|
||||
let store = TorrentStore {
|
||||
torrents,
|
||||
filter,
|
||||
search_query,
|
||||
global_stats,
|
||||
notifications,
|
||||
};
|
||||
let show_browser_notification = crate::utils::notification::use_app_notification();
|
||||
|
||||
let store = TorrentStore { torrents, filter, search_query, global_stats, user, selected_torrent, push_enabled };
|
||||
provide_context(store);
|
||||
|
||||
// Initialize SSE connection with auto-reconnect
|
||||
create_effect(move |_| {
|
||||
spawn_local(async move {
|
||||
let mut backoff_ms: u32 = 1000; // Start with 1 second
|
||||
let max_backoff_ms: u32 = 30000; // Max 30 seconds
|
||||
let mut was_connected = false;
|
||||
let mut disconnect_notified = false; // Track if we already showed disconnect toast
|
||||
let mut got_first_message; // Only count as "connected" after receiving data
|
||||
// Initial check for push status
|
||||
spawn_local(async move {
|
||||
if let Ok(enabled) = is_push_subscribed().await {
|
||||
push_enabled.set(enabled);
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
let es_result = EventSource::new("/api/events");
|
||||
|
||||
match es_result {
|
||||
Ok(mut es) => {
|
||||
match es.subscribe("message") {
|
||||
Ok(mut stream) => {
|
||||
// Don't show "connected" toast yet - wait for first real message
|
||||
got_first_message = false;
|
||||
let global_stats_for_sse = global_stats;
|
||||
let torrents_for_sse = torrents;
|
||||
let show_browser_notification = show_browser_notification.clone();
|
||||
|
||||
// Process messages
|
||||
while let Some(Ok((_, msg))) = stream.next().await {
|
||||
// First successful message = truly connected
|
||||
if !got_first_message {
|
||||
got_first_message = true;
|
||||
backoff_ms = 1000; // Reset backoff on real data
|
||||
|
||||
if was_connected && disconnect_notified {
|
||||
// We were previously connected, lost connection, and now truly reconnected
|
||||
show_toast_with_signal(
|
||||
notifications,
|
||||
NotificationLevel::Success,
|
||||
"Sunucu bağlantısı yeniden kuruldu",
|
||||
);
|
||||
disconnect_notified = false;
|
||||
}
|
||||
was_connected = true;
|
||||
}
|
||||
spawn_local(async move {
|
||||
let mut backoff_ms: u32 = 1000;
|
||||
let mut was_connected = false;
|
||||
let mut disconnect_notified = false;
|
||||
|
||||
if let Some(data_str) = msg.data().as_string() {
|
||||
if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) {
|
||||
match event {
|
||||
AppEvent::FullList { torrents: list, .. } => {
|
||||
torrents.set(list);
|
||||
loop {
|
||||
let es_result = EventSource::new("/api/events");
|
||||
match es_result {
|
||||
Ok(mut es) => {
|
||||
if let Ok(mut stream) = es.subscribe("message") {
|
||||
let mut got_first_message = false;
|
||||
while let Some(Ok((_, msg))) = stream.next().await {
|
||||
if !got_first_message {
|
||||
got_first_message = true;
|
||||
backoff_ms = 1000;
|
||||
if was_connected && disconnect_notified {
|
||||
show_toast(NotificationLevel::Success, "Sunucu bağlantısı yeniden kuruldu");
|
||||
disconnect_notified = false;
|
||||
}
|
||||
was_connected = true;
|
||||
}
|
||||
|
||||
if let Some(data_str) = msg.data().as_string() {
|
||||
if let Ok(bytes) = BASE64.decode(&data_str) {
|
||||
if let Ok(event) = rmp_serde::from_slice::<AppEvent>(&bytes) {
|
||||
match event {
|
||||
AppEvent::FullList(list, _) => {
|
||||
torrents_for_sse.update(|map| {
|
||||
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
|
||||
map.retain(|hash, _| new_hashes.contains(hash));
|
||||
for new_torrent in list { map.insert(new_torrent.hash.clone(), new_torrent); }
|
||||
});
|
||||
}
|
||||
AppEvent::Update(patch) => {
|
||||
if let Some(hash) = patch.hash.clone() {
|
||||
torrents_for_sse.update(|map| { if let Some(t) = map.get_mut(&hash) { t.apply(patch); } });
|
||||
}
|
||||
AppEvent::Update(update) => {
|
||||
torrents.update(|list| {
|
||||
if let Some(t) = list.iter_mut().find(|t| t.hash == update.hash)
|
||||
{
|
||||
if let Some(name) = update.name {
|
||||
t.name = name;
|
||||
}
|
||||
if let Some(size) = update.size {
|
||||
t.size = size;
|
||||
}
|
||||
if let Some(down_rate) = update.down_rate {
|
||||
t.down_rate = down_rate;
|
||||
}
|
||||
if let Some(up_rate) = update.up_rate {
|
||||
t.up_rate = up_rate;
|
||||
}
|
||||
if let Some(percent_complete) = update.percent_complete {
|
||||
t.percent_complete = percent_complete;
|
||||
}
|
||||
if let Some(completed) = update.completed {
|
||||
t.completed = completed;
|
||||
}
|
||||
if let Some(eta) = update.eta {
|
||||
t.eta = eta;
|
||||
}
|
||||
if let Some(status) = update.status {
|
||||
t.status = status;
|
||||
}
|
||||
if let Some(error_message) = update.error_message {
|
||||
t.error_message = error_message;
|
||||
}
|
||||
if let Some(label) = update.label {
|
||||
t.label = Some(label);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
AppEvent::Stats(stats) => {
|
||||
global_stats.set(stats);
|
||||
}
|
||||
AppEvent::Notification(n) => {
|
||||
// Show toast notification
|
||||
show_toast_with_signal(notifications, n.level.clone(), n.message.clone());
|
||||
|
||||
// Show browser notification for critical events
|
||||
let is_critical = n.message.contains("tamamlandı")
|
||||
|| n.level == shared::NotificationLevel::Error;
|
||||
|
||||
if is_critical {
|
||||
let title = match n.level {
|
||||
shared::NotificationLevel::Success => "✅ VibeTorrent",
|
||||
shared::NotificationLevel::Error => "❌ VibeTorrent",
|
||||
shared::NotificationLevel::Warning => "⚠️ VibeTorrent",
|
||||
shared::NotificationLevel::Info => "ℹ️ VibeTorrent",
|
||||
};
|
||||
|
||||
crate::utils::notification::show_notification_if_enabled(
|
||||
title,
|
||||
&n.message
|
||||
);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended - connection lost
|
||||
if was_connected && !disconnect_notified {
|
||||
show_toast_with_signal(
|
||||
notifications,
|
||||
NotificationLevel::Warning,
|
||||
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
|
||||
);
|
||||
disconnect_notified = true;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Failed to subscribe - only notify once
|
||||
if was_connected && !disconnect_notified {
|
||||
show_toast_with_signal(
|
||||
notifications,
|
||||
NotificationLevel::Warning,
|
||||
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
|
||||
);
|
||||
disconnect_notified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Failed to create EventSource - only notify once
|
||||
if was_connected && !disconnect_notified {
|
||||
show_toast_with_signal(
|
||||
notifications,
|
||||
NotificationLevel::Warning,
|
||||
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
|
||||
);
|
||||
show_toast(NotificationLevel::Warning, "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...");
|
||||
disconnect_notified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before reconnecting (exponential backoff)
|
||||
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
|
||||
backoff_ms = std::cmp::min(backoff_ms * 2, max_backoff_ms);
|
||||
Err(_) => {
|
||||
if was_connected && !disconnect_notified {
|
||||
show_toast(NotificationLevel::Warning, "Sunucu bağlantısı kurulamıyor...");
|
||||
disconnect_notified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
|
||||
backoff_ms = std::cmp::min(backoff_ms * 2, 30000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Push Notification Subscription
|
||||
// ============================================================================
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct PushSubscriptionData {
|
||||
endpoint: String,
|
||||
keys: PushKeys,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct PushKeys {
|
||||
p256dh: String,
|
||||
auth: String,
|
||||
}
|
||||
|
||||
/// Subscribe user to push notifications
|
||||
/// Requests notification permission if needed, then subscribes to push
|
||||
pub async fn subscribe_to_push_notifications() {
|
||||
use gloo_net::http::Request;
|
||||
|
||||
// First, request notification permission if not already granted
|
||||
let window = web_sys::window().expect("window should exist");
|
||||
let permission_granted = if let Ok(notification_class) = js_sys::Reflect::get(&window, &"Notification".into()) {
|
||||
if notification_class.is_undefined() {
|
||||
log::error!("Notification API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check current permission
|
||||
let current_permission = js_sys::Reflect::get(¬ification_class, &"permission".into())
|
||||
.ok()
|
||||
.and_then(|p| p.as_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if current_permission == "granted" {
|
||||
log::info!("Notification permission already granted");
|
||||
true
|
||||
} else if current_permission == "denied" {
|
||||
log::warn!("Notification permission was denied");
|
||||
return;
|
||||
} else {
|
||||
// Permission is "default" - need to request
|
||||
log::info!("Requesting notification permission...");
|
||||
if let Ok(request_fn) = js_sys::Reflect::get(¬ification_class, &"requestPermission".into()) {
|
||||
if request_fn.is_function() {
|
||||
let request_fn_typed = js_sys::Function::from(request_fn);
|
||||
match request_fn_typed.call0(¬ification_class) {
|
||||
Ok(promise_val) => {
|
||||
let request_future = wasm_bindgen_futures::JsFuture::from(
|
||||
js_sys::Promise::from(promise_val)
|
||||
);
|
||||
match request_future.await {
|
||||
Ok(result) => {
|
||||
let result_str = result.as_string().unwrap_or_default();
|
||||
log::info!("Permission request result: {}", result_str);
|
||||
result_str == "granted"
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to request notification permission: {:?}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to call requestPermission: {:?}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("Cannot access Notification class");
|
||||
return;
|
||||
};
|
||||
|
||||
if !permission_granted {
|
||||
log::warn!("Notification permission not granted, cannot subscribe to push");
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("Notification permission granted! Proceeding with push subscription...");
|
||||
|
||||
// Get VAPID public key from backend
|
||||
let public_key_response = match Request::get("/api/push/public-key").send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
log::error!("Failed to get VAPID public key: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let public_key_data: serde_json::Value = match public_key_response.json().await {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse VAPID public key: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let public_key = match public_key_data.get("publicKey").and_then(|v| v.as_str()) {
|
||||
Some(key) => key,
|
||||
None => {
|
||||
log::error!("Missing publicKey in response");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
log::info!("VAPID public key from backend: {} (len: {})", public_key, public_key.len());
|
||||
|
||||
// Convert VAPID public key to Uint8Array
|
||||
let public_key_array = match url_base64_to_uint8array(public_key) {
|
||||
Ok(arr) => {
|
||||
log::info!("VAPID key converted to Uint8Array (length: {})", arr.length());
|
||||
arr
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to convert VAPID key: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Get service worker registration
|
||||
let window = web_sys::window().expect("window should exist");
|
||||
pub async fn is_push_subscribed() -> Result<bool, String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let navigator = window.navigator();
|
||||
let service_worker = navigator.service_worker();
|
||||
let sw_container = navigator.service_worker();
|
||||
|
||||
let registration_promise = match service_worker.ready() {
|
||||
Ok(promise) => promise,
|
||||
Err(e) => {
|
||||
log::error!("Failed to get ready promise: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let registration_future = wasm_bindgen_futures::JsFuture::from(registration_promise);
|
||||
|
||||
let registration = match registration_future.await {
|
||||
Ok(reg) => reg,
|
||||
Err(e) => {
|
||||
log::error!("Failed to get service worker registration: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let service_worker_registration = registration
|
||||
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>()
|
||||
.expect("should be ServiceWorkerRegistration");
|
||||
|
||||
// Subscribe to push
|
||||
let push_manager = match service_worker_registration.push_manager() {
|
||||
Ok(pm) => pm,
|
||||
Err(e) => {
|
||||
log::error!("Failed to get push manager: {:?}", e);
|
||||
return;
|
||||
}
|
||||
.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() {
|
||||
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; }
|
||||
};
|
||||
|
||||
let subscribe_options = web_sys::PushSubscriptionOptionsInit::new();
|
||||
subscribe_options.set_user_visible_only(true);
|
||||
subscribe_options.set_application_server_key(&public_key_array);
|
||||
|
||||
let subscribe_promise = match push_manager.subscribe_with_options(&subscribe_options) {
|
||||
Ok(promise) => promise,
|
||||
Err(e) => {
|
||||
log::error!("Failed to subscribe to push: {:?}", 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; }
|
||||
};
|
||||
|
||||
let subscription_future = wasm_bindgen_futures::JsFuture::from(subscribe_promise);
|
||||
|
||||
let subscription = match subscription_future.await {
|
||||
Ok(sub) => sub,
|
||||
Err(e) => {
|
||||
log::error!("Failed to get push subscription: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let push_subscription = subscription
|
||||
.dyn_into::<web_sys::PushSubscription>()
|
||||
.expect("should be PushSubscription");
|
||||
|
||||
// Get subscription JSON using toJSON() method
|
||||
let json_result = match js_sys::Reflect::get(&push_subscription, &"toJSON".into()) {
|
||||
Ok(func) if func.is_function() => {
|
||||
let json_func = js_sys::Function::from(func);
|
||||
match json_func.call0(&push_subscription) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
log::error!("Failed to call toJSON: {:?}", 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 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),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
log::error!("toJSON method not found on PushSubscription");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let json_value = match js_sys::JSON::stringify(&json_result) {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
log::error!("Failed to stringify subscription: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let subscription_json_str = json_value.as_string().expect("should be string");
|
||||
|
||||
log::info!("Push subscription: {}", subscription_json_str);
|
||||
|
||||
// Parse and send to backend
|
||||
let subscription_data: serde_json::Value = match serde_json::from_str(&subscription_json_str) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse subscription JSON: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract endpoint and keys
|
||||
let endpoint = subscription_data
|
||||
.get("endpoint")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("endpoint should exist")
|
||||
.to_string();
|
||||
|
||||
let keys_obj = subscription_data
|
||||
.get("keys")
|
||||
.expect("keys should exist");
|
||||
|
||||
let p256dh = keys_obj
|
||||
.get("p256dh")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("p256dh should exist")
|
||||
.to_string();
|
||||
|
||||
let auth = keys_obj
|
||||
.get("auth")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("auth should exist")
|
||||
.to_string();
|
||||
|
||||
let push_data = PushSubscriptionData {
|
||||
endpoint,
|
||||
keys: PushKeys { p256dh, auth },
|
||||
};
|
||||
|
||||
// Send to backend
|
||||
let response = match Request::post("/api/push/subscribe")
|
||||
.json(&push_data)
|
||||
.expect("serialization should succeed")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
log::error!("Failed to send subscription to backend: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if response.ok() {
|
||||
log::info!("Successfully subscribed to push notifications");
|
||||
} else {
|
||||
log::error!("Backend rejected push subscription: {:?}", response.status());
|
||||
Err(e) => log::error!("Subscription Error: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to convert URL-safe base64 string to Uint8Array
|
||||
/// Uses JavaScript to properly decode binary data (avoids UTF-8 encoding issues)
|
||||
fn url_base64_to_uint8array(base64_string: &str) -> Result<js_sys::Uint8Array, JsValue> {
|
||||
// Add padding
|
||||
let padding = (4 - (base64_string.len() % 4)) % 4;
|
||||
let mut padded = base64_string.to_string();
|
||||
padded.push_str(&"=".repeat(padding));
|
||||
pub async fn unsubscribe_from_push_notifications() {
|
||||
let window = web_sys::window().expect("no window");
|
||||
let sw_container = window.navigator().service_worker();
|
||||
|
||||
// Replace URL-safe characters
|
||||
let standard_base64 = padded.replace('-', "+").replace('_', "/");
|
||||
|
||||
// Decode using JavaScript to avoid UTF-8 encoding issues
|
||||
// Create a JavaScript function to decode the base64 and convert to Uint8Array
|
||||
let js_code = format!(
|
||||
r#"
|
||||
(function() {{
|
||||
const binaryString = atob('{}');
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {{
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}}
|
||||
return bytes;
|
||||
}})()
|
||||
"#,
|
||||
standard_base64
|
||||
);
|
||||
|
||||
let result = js_sys::eval(&js_code)?;
|
||||
let array = result.dyn_into::<js_sys::Uint8Array>()?;
|
||||
|
||||
Ok(array)
|
||||
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ı");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,21 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::{Notification, NotificationOptions};
|
||||
|
||||
|
||||
/// Request browser notification permission from user
|
||||
pub async fn request_notification_permission() -> bool {
|
||||
let window = web_sys::window().expect("no global window");
|
||||
|
||||
// Check if Notification API is available
|
||||
if js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false) {
|
||||
let notification = js_sys::Reflect::get(&window, &JsValue::from_str("Notification"))
|
||||
.expect("Notification should exist");
|
||||
|
||||
// Request permission
|
||||
let promise = js_sys::Reflect::get(¬ification, &JsValue::from_str("requestPermission"))
|
||||
.expect("requestPermission should exist");
|
||||
|
||||
if let Ok(function) = promise.dyn_into::<js_sys::Function>() {
|
||||
if let Ok(promise) = function.call0(¬ification) {
|
||||
if let Ok(promise) = promise.dyn_into::<js_sys::Promise>() {
|
||||
let result = wasm_bindgen_futures::JsFuture::from(promise).await;
|
||||
|
||||
if let Ok(permission) = result {
|
||||
let permission_str = permission.as_string().unwrap_or_default();
|
||||
return permission_str == "granted";
|
||||
}
|
||||
}
|
||||
}
|
||||
if !is_notification_supported() {
|
||||
return false;
|
||||
}
|
||||
if let Ok(promise) = Notification::request_permission() {
|
||||
if let Ok(result) = wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||
return result.as_string().unwrap_or_default() == "granted";
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if browser notifications are supported and permitted
|
||||
/// Check if browser notifications are supported
|
||||
pub fn is_notification_supported() -> bool {
|
||||
let window = web_sys::window().expect("no global window");
|
||||
js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false)
|
||||
@@ -40,66 +24,61 @@ pub fn is_notification_supported() -> bool {
|
||||
/// Get current notification permission status
|
||||
pub fn get_notification_permission() -> String {
|
||||
if !is_notification_supported() {
|
||||
return "unsupported".to_string();
|
||||
return "denied".to_string();
|
||||
}
|
||||
match Notification::permission() {
|
||||
web_sys::NotificationPermission::Granted => "granted".to_string(),
|
||||
web_sys::NotificationPermission::Denied => "denied".to_string(),
|
||||
web_sys::NotificationPermission::Default => "default".to_string(),
|
||||
_ => "default".to_string(),
|
||||
}
|
||||
|
||||
let window = web_sys::window().expect("no global window");
|
||||
let notification = js_sys::Reflect::get(&window, &JsValue::from_str("Notification"))
|
||||
.expect("Notification should exist");
|
||||
|
||||
let permission = js_sys::Reflect::get(¬ification, &JsValue::from_str("permission"))
|
||||
.unwrap_or(JsValue::from_str("default"));
|
||||
|
||||
permission.as_string().unwrap_or("default".to_string())
|
||||
}
|
||||
|
||||
/// Show a browser notification
|
||||
/// Returns true if notification was shown successfully
|
||||
pub fn show_browser_notification(title: &str, body: &str, icon: Option<&str>) -> bool {
|
||||
// Check permission first
|
||||
let permission = get_notification_permission();
|
||||
if permission != "granted" {
|
||||
log::warn!("Notification permission not granted: {}", permission);
|
||||
/// Hook for using browser notifications within Leptos components or effects.
|
||||
/// This uses leptos-use for reactive permission tracking.
|
||||
pub fn use_app_notification() -> impl Fn(&str, &str) + Clone {
|
||||
move |title: &str, body: &str| {
|
||||
// Check user preference from localStorage
|
||||
let window = web_sys::window().expect("no global window");
|
||||
let storage = window.local_storage().ok().flatten();
|
||||
let enabled = storage
|
||||
.and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten())
|
||||
.unwrap_or_else(|| "true".to_string());
|
||||
|
||||
// Check platform support and permission
|
||||
if enabled == "true" && is_notification_supported() && get_notification_permission() == "granted" {
|
||||
show_browser_notification(title, body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a browser notification (non-reactive version)
|
||||
pub fn show_browser_notification(title: &str, body: &str) -> bool {
|
||||
if get_notification_permission() != "granted" {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create notification options
|
||||
let opts = NotificationOptions::new();
|
||||
opts.set_body(body);
|
||||
opts.set_icon(icon.unwrap_or("/icon-192.png"));
|
||||
opts.set_badge("/icon-192.png");
|
||||
opts.set_icon("/icon-192.png");
|
||||
opts.set_tag("vibetorrent");
|
||||
opts.set_require_interaction(false);
|
||||
opts.set_silent(Some(false));
|
||||
|
||||
// Create and show notification
|
||||
match Notification::new_with_options(title, &opts) {
|
||||
Ok(_notification) => {
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create notification: {:?}", e);
|
||||
false
|
||||
}
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show notification only if enabled in settings and permission granted
|
||||
/// Legacy helper for showing notification if enabled in settings
|
||||
pub fn show_notification_if_enabled(title: &str, body: &str) -> bool {
|
||||
// Check localStorage for user preference
|
||||
let window = web_sys::window().expect("no global window");
|
||||
let storage = window.local_storage().ok().flatten();
|
||||
|
||||
if let Some(storage) = storage {
|
||||
let enabled = storage
|
||||
.get_item("vibetorrent_browser_notifications")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or("true".to_string());
|
||||
let enabled = storage
|
||||
.and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten())
|
||||
.unwrap_or_else(|| "true".to_string());
|
||||
|
||||
if enabled == "true" {
|
||||
return show_browser_notification(title, body, None);
|
||||
}
|
||||
if enabled == "true" {
|
||||
return show_browser_notification(title, body);
|
||||
}
|
||||
|
||||
false
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = "vibetorrent-v2";
|
||||
const CACHE_NAME = "vibetorrent-v3";
|
||||
const ASSETS_TO_CACHE = [
|
||||
"/",
|
||||
"/index.html",
|
||||
@@ -51,6 +51,11 @@ self.addEventListener("activate", (event) => {
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Skip unsupported schemes (like chrome-extension://)
|
||||
if (!url.protocol.startsWith("http")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first strategy for API calls
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
event.respondWith(
|
||||
@@ -75,10 +80,12 @@ self.addEventListener("fetch", (event) => {
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
// Cache the latest version of the HTML
|
||||
const responseToCache = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
if (response && response.status === 200) {
|
||||
const responseToCache = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
// Cargo registry'deki leptos-shadcn crate'lerini Tailwind'e taratmak için
|
||||
const cargoRegistry = path.join(
|
||||
os.homedir(),
|
||||
".cargo/registry/src/*/leptos-shadcn-*/src/**/*.rs"
|
||||
);
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./index.html", "./src/**/*.{rs,html}"],
|
||||
darkMode: "class",
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{rs,html}",
|
||||
cargoRegistry,
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
@@ -12,4 +26,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("tailwindcss-animate"),
|
||||
],
|
||||
};
|
||||
|
||||
2
frontend/ui_config.toml
Normal file
2
frontend/ui_config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
base_color = "neutral"
|
||||
base_path_components = "src/components"
|
||||
372
package-lock.json
generated
Normal file
372
package-lock.json
generated
Normal file
@@ -0,0 +1,372 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
package.json
Normal file
8
package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
}
|
||||
}
|
||||
30
patches/coarsetime/Cargo.toml
Normal file
30
patches/coarsetime/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
edition = "2018"
|
||||
name = "coarsetime"
|
||||
version = "0.1.37"
|
||||
description = "Time and duration crate optimized for speed (patched for MIPS)"
|
||||
license = "BSD-2-Clause"
|
||||
|
||||
[features]
|
||||
wasi-abi2 = ["dep:wasi-abi2"]
|
||||
|
||||
[lib]
|
||||
name = "coarsetime"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
portable-atomic = { version = "1", default-features = false, features = ["fallback"] }
|
||||
|
||||
[target.'cfg(all(any(target_arch = "wasm32", target_arch = "wasm64"), target_os = "unknown"))'.dependencies.wasm-bindgen]
|
||||
version = "0.2"
|
||||
|
||||
[target.'cfg(any(target_os = "wasix", target_os = "wasi"))'.dependencies.wasix]
|
||||
version = "0.13"
|
||||
|
||||
[target.'cfg(not(any(target_os = "wasix", target_os = "wasi")))'.dependencies.libc]
|
||||
version = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "wasi")'.dependencies.wasi-abi2]
|
||||
version = "0.14.7"
|
||||
optional = true
|
||||
package = "wasi"
|
||||
@@ -4,9 +4,6 @@
|
||||
)))]
|
||||
use std::time;
|
||||
|
||||
#[cfg(target_has_atomic = "64")]
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
#[cfg(not(target_has_atomic = "64"))]
|
||||
use portable_atomic::{AtomicU64, Ordering};
|
||||
|
||||
use super::Duration;
|
||||
@@ -3,9 +3,6 @@ use std::mem::MaybeUninit;
|
||||
use std::ops::*;
|
||||
#[allow(unused_imports)]
|
||||
use std::ptr::*;
|
||||
#[cfg(target_has_atomic = "64")]
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
#[cfg(not(target_has_atomic = "64"))]
|
||||
use portable_atomic::{AtomicU64, Ordering};
|
||||
|
||||
use super::duration::*;
|
||||
@@ -1,8 +1,50 @@
|
||||
[package]
|
||||
name = "shared"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||
struct-patch = "0.5"
|
||||
rmp-serde = "1.3"
|
||||
bytes = "1"
|
||||
http = "1"
|
||||
|
||||
# Leptos 0.8.7
|
||||
leptos = { version = "0.8.15", features = ["nightly", "msgpack"] }
|
||||
leptos_router = { version = "0.8.7", features = ["nightly"] }
|
||||
leptos_axum = { version = "0.8.7", optional = true }
|
||||
axum = { version = "0.8", features = ["macros"], optional = true }
|
||||
|
||||
# SSR Dependencies (XML-RPC & SCGI)
|
||||
tokio = { version = "1", features = ["full"], optional = true }
|
||||
thiserror = { version = "2", optional = true }
|
||||
quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = true }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"], optional = true }
|
||||
anyhow = { version = "1.0", optional = true }
|
||||
|
||||
# Auth (SSR)
|
||||
jsonwebtoken = { version = "9", optional = true }
|
||||
cookie = { version = "0.18", features = ["percent-encode"], optional = true }
|
||||
bcrypt = { version = "0.17", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
ssr = [
|
||||
"dep:tokio",
|
||||
"dep:thiserror",
|
||||
"dep:quick-xml",
|
||||
"dep:leptos_axum",
|
||||
"dep:sqlx",
|
||||
"dep:anyhow",
|
||||
"dep:jsonwebtoken",
|
||||
"dep:cookie",
|
||||
"dep:bcrypt",
|
||||
"dep:axum",
|
||||
"leptos/ssr",
|
||||
"leptos_router/ssr",
|
||||
]
|
||||
hydrate = ["leptos/hydrate"]
|
||||
|
||||
16
shared/migrations/001_init.sql
Normal file
16
shared/migrations/001_init.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- 001_init.sql
|
||||
-- Initial schema for users and sessions
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
13
shared/migrations/002_push_subscriptions.sql
Normal file
13
shared/migrations/002_push_subscriptions.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- 002_push_subscriptions.sql
|
||||
-- Push notification subscriptions storage
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index for faster lookups by endpoint
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_endpoint ON push_subscriptions(endpoint);
|
||||
1
shared/src/codec.rs
Normal file
1
shared/src/codec.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub use leptos::server_fn::codec::MsgPack;
|
||||
148
shared/src/db.rs
Normal file
148
shared/src/db.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite, Row, sqlite::SqliteConnectOptions};
|
||||
use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
pool: Pool<Sqlite>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub async fn new(db_url: &str) -> Result<Self> {
|
||||
let options = SqliteConnectOptions::from_str(db_url)?
|
||||
.create_if_missing(true)
|
||||
.busy_timeout(Duration::from_secs(10)) // Bekleme süresini 10 saniyeye çıkardık
|
||||
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
|
||||
.synchronous(sqlx::sqlite::SqliteSynchronous::Normal);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
|
||||
let db = Self { pool };
|
||||
db.run_migrations().await?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
async fn run_migrations(&self) -> Result<()> {
|
||||
sqlx::migrate!("./migrations").run(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- User Operations ---
|
||||
|
||||
pub async fn create_user(&self, username: &str, password_hash: &str) -> Result<()> {
|
||||
sqlx::query("INSERT INTO users (username, password_hash) VALUES (?, ?)")
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(&self, username: &str) -> Result<Option<(i64, String)>> {
|
||||
let row = sqlx::query("SELECT id, password_hash FROM users WHERE username = ?")
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| (r.get(0), r.get(1))))
|
||||
}
|
||||
|
||||
pub async fn get_username_by_id(&self, id: i64) -> Result<Option<String>> {
|
||||
let row = sqlx::query("SELECT username FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| r.get(0)))
|
||||
}
|
||||
|
||||
pub async fn has_users(&self) -> Result<bool> {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.0 > 0)
|
||||
}
|
||||
|
||||
// --- Session Operations ---
|
||||
|
||||
pub async fn create_session(&self, user_id: i64, token: &str, expires_at: i64) -> Result<()> {
|
||||
sqlx::query("INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime(?, 'unixepoch'))")
|
||||
.bind(token)
|
||||
.bind(user_id)
|
||||
.bind(expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_session_user(&self, token: &str) -> Result<Option<i64>> {
|
||||
let row = sqlx::query("SELECT user_id FROM sessions WHERE token = ? AND expires_at > datetime('now')")
|
||||
.bind(token)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| r.get(0)))
|
||||
}
|
||||
|
||||
pub async fn delete_session(&self, token: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM sessions WHERE token = ?")
|
||||
.bind(token)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_password(&self, user_id: i64, password_hash: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
.bind(password_hash)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_all_sessions_for_user(&self, user_id: i64) -> Result<()> {
|
||||
sqlx::query("DELETE FROM sessions WHERE user_id = ?")
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Push Subscription Operations ---
|
||||
|
||||
pub async fn save_push_subscription(&self, endpoint: &str, p256dh: &str, auth: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO push_subscriptions (endpoint, p256dh, auth) VALUES (?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth"
|
||||
)
|
||||
.bind(endpoint)
|
||||
.bind(p256dh)
|
||||
.bind(auth)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_push_subscription(&self, endpoint: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM push_subscriptions WHERE endpoint = ?")
|
||||
.bind(endpoint)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_all_push_subscriptions(&self) -> Result<Vec<(String, String, String)>> {
|
||||
let rows = sqlx::query_as::<_, (String, String, String)>(
|
||||
"SELECT endpoint, p256dh, auth FROM push_subscriptions"
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user