Compare commits

...

66 Commits

Author SHA1 Message Date
spinline
5a8f5169ea perf: implement smart merge logic for FullList to preserve reactive references
All checks were successful
Build MIPS Binary / build (push) Successful in 4m22s
2026-02-09 00:40:09 +03:00
spinline
afdc34e131 perf: use keyed <For /> and fine-grained reactivity in torrent table
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-09 00:39:44 +03:00
spinline
d15392e148 fix: add timeout to SCGI requests to prevent background loop hang
All checks were successful
Build MIPS Binary / build (push) Successful in 4m22s
2026-02-09 00:28:07 +03:00
spinline
f3121898e2 fix: wake up background polling loop immediately when a client connects
All checks were successful
Build MIPS Binary / build (push) Successful in 4m22s
2026-02-09 00:22:28 +03:00
spinline
e1370db6ce ui: rename Down Speed to DL Speed in torrent table
All checks were successful
Build MIPS Binary / build (push) Successful in 4m26s
2026-02-09 00:07:07 +03:00
spinline
1432dec828 perf: implement dynamic polling interval based on active clients
All checks were successful
Build MIPS Binary / build (push) Successful in 4m35s
2026-02-08 23:57:32 +03:00
spinline
1bb3475d61 perf: optimize torrent store with HashMap for O(1) updates
All checks were successful
Build MIPS Binary / build (push) Successful in 4m57s
2026-02-08 23:52:23 +03:00
spinline
cffc88443a feat: add centralized API service layer for frontend
All checks were successful
Build MIPS Binary / build (push) Successful in 5m18s
- Create frontend/src/api/mod.rs with centralized HTTP client and error handling
- Implement api::auth module (login, logout, check_auth, get_user)
- Implement api::torrent module (add, action, delete, start, stop, set_label, set_priority)
- Implement api::setup module (get_status, setup)
- Implement api::settings module (set_global_limits)
- Implement api::push module (get_public_key, subscribe)
- Update all components to use api service layer instead of direct gloo_net calls
- Add thiserror dependency for error handling
2026-02-08 23:27:13 +03:00
spinline
129a4c7586 refactor: move AddTorrentRequest to shared library for type safety
All checks were successful
Build MIPS Binary / build (push) Successful in 4m28s
2026-02-08 22:43:35 +03:00
spinline
f2dfa7963e refactor: modernize browser notifications with leptos-use and refactor utils/notification.rs
All checks were successful
Build MIPS Binary / build (push) Successful in 4m31s
2026-02-08 22:30:46 +03:00
spinline
d3792e78e0 fix: ios safari mobile dropdown and click bubbling issues resolved with global closer and native details
All checks were successful
Build MIPS Binary / build (push) Successful in 5m45s
2026-02-08 21:49:00 +03:00
spinline
384165a958 fix: ios safari mobile dropdown and click bubbling issues resolved with cursor-pointer and css fixes
All checks were successful
Build MIPS Binary / build (push) Successful in 4m27s
2026-02-08 21:28:10 +03:00
spinline
7169e44f4e fix: incorrect import of on_click_outside in statusbar.rs
All checks were successful
Build MIPS Binary / build (push) Successful in 4m28s
2026-02-08 21:00:32 +03:00
spinline
51fb85c2d8 fix: iOS Safari uyumluluğu için menü yapıları ve click bubbling sorunları giderildi
Some checks failed
Build MIPS Binary / build (push) Failing after 1m8s
2026-02-08 20:58:14 +03:00
spinline
46882caea0 fix: mobilde menülerin açılmama sorunu HTML details/summary yapısı ile kökten çözüldü
All checks were successful
Build MIPS Binary / build (push) Successful in 4m31s
2026-02-08 20:47:42 +03:00
spinline
e339ca1c49 fix: menülerin dışarıya tıklayınca kapanmama sorunu giderildi (stop_propagation kaldırıldı ve click event'ine geçildi)
All checks were successful
Build MIPS Binary / build (push) Successful in 4m34s
2026-02-08 20:35:44 +03:00
spinline
a08fd9698f refactor: açılır menüler leptos-use::on_click_outside ile modernize edildi, şeffaf backdrop katmanları kaldırıldı
All checks were successful
Build MIPS Binary / build (push) Successful in 5m52s
2026-02-08 20:16:05 +03:00
spinline
7d46dbd437 refactor: tema yönetimi leptos-use::use_local_storage ile reaktif hale getirildi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m28s
2026-02-08 20:02:01 +03:00
spinline
5f107299e3 refactor: long press mantığı leptos-use::use_timeout_fn ile modernize edildi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m29s
2026-02-08 19:53:10 +03:00
spinline
c34133ded1 refactor: manuel Closure ve timer yönetimi Leptos set_timeout ile değiştirildi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m16s
2026-02-08 19:39:38 +03:00
spinline
0d059cbbd3 fix: push notification permission ve toJSON çağrıları tip güvenli hale getirildi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m14s
2026-02-08 19:30:05 +03:00
spinline
fc83a1cc65 refactor: js_sys::eval kullanımı kaldırıldı, Base64 çözümleme pure Rust ile güncellendi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m14s
2026-02-08 19:25:36 +03:00
spinline
4e81af0599 fix: unused import warning for utoipa::OpenApi in production build
All checks were successful
Build MIPS Binary / build (push) Successful in 4m18s
2026-02-08 18:49:54 +03:00
spinline
74c3c5c17e feat: Swagger UI varsayılan (dev) özelliklere eklendi, production build'inden muaf tutuldu
All checks were successful
Build MIPS Binary / build (push) Successful in 4m18s
2026-02-08 18:43:55 +03:00
spinline
3632a578e1 build: CI/CD ve optimizasyon süreci en sade ve güvenilir haline getirildi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 18:41:40 +03:00
spinline
8a9905fc56 fix: WASM dosyasının bozulmasına neden olan hatalı manuel optimizasyon adımı kaldırıldı
All checks were successful
Build MIPS Binary / build (push) Successful in 6m45s
2026-02-08 18:21:40 +03:00
spinline
1e39cbb0c5 perf: backend binary boyutunu düşürmek için Swagger UI opsiyonel yapıldı ve build komutu optimize edildi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m31s
2026-02-08 18:16:45 +03:00
spinline
40be58f2fc perf: backend derleme süreci kök dizine taşınarak workspace optimizasyonları aktif edildi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m30s
2026-02-08 18:10:09 +03:00
spinline
3f08b5b54a perf: WASM boyut takibi loglara eklendi ve profil çakışmaları giderildi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m27s
2026-02-08 18:03:52 +03:00
spinline
bfec99ae35 fix: wasm-opt için --all-features bayrağı kullanılarak flag uyuşmazlığı giderildi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m28s
2026-02-08 17:58:14 +03:00
spinline
d9afd3aa81 fix: wasm-opt için nontrapping-float-to-int-conversions özelliği eklendi
Some checks failed
Build MIPS Binary / build (push) Failing after 1m2s
2026-02-08 17:56:28 +03:00
spinline
e72113d91d perf: manuel WASM optimizasyonu eklendi ve build süreci stabilize edildi
Some checks failed
Build MIPS Binary / build (push) Failing after 1m2s
2026-02-08 17:54:43 +03:00
spinline
7c4ff619c1 fix: .cargo/config.toml yazım hatası düzeltildi
Some checks failed
Build MIPS Binary / build (push) Failing after 1m2s
2026-02-08 17:52:15 +03:00
spinline
9c4217f450 feat: WASM için bulk-memory özelliği aktif edildi
Some checks failed
Build MIPS Binary / build (push) Failing after 3s
2026-02-08 17:51:04 +03:00
spinline
cc09002171 trigger: yeniden build başlatıldı
Some checks failed
Build MIPS Binary / build (push) Failing after 1m3s
2026-02-08 17:49:06 +03:00
spinline
5d8cdd7760 build: build ortamı güncellendi (Trunk v0.21.14 ve binaryen eklendi), optimizasyonlar tekrar açıldı
Some checks failed
Build MIPS Binary / build (push) Failing after 1m2s
2026-02-08 16:51:40 +03:00
spinline
145436eefc fix: build hatasını aşmak için wasm-opt geçici olarak devre dışı bırakıldı
All checks were successful
Build MIPS Binary / build (push) Successful in 4m30s
2026-02-08 16:44:29 +03:00
spinline
10c95c5ff3 fix: wasm-opt build hatası için rustc ve wasm-opt versiyon ayarları güncellendi
Some checks failed
Build MIPS Binary / build (push) Failing after 1m8s
2026-02-08 16:42:13 +03:00
spinline
329654cc4e fix: wasm-opt build hatası için bulk-memory özelliği devre dışı bırakıldı
Some checks failed
Build MIPS Binary / build (push) Failing after 1m31s
2026-02-08 16:37:45 +03:00
spinline
22b592a652 fix: wasm-opt seviyesi 'z' olarak güncellendi
Some checks failed
Build MIPS Binary / build (push) Failing after 1m35s
2026-02-08 16:33:46 +03:00
spinline
817dc49db2 fix: wasm-opt build hatası için --enable-bulk-memory flag'i eklendi
Some checks failed
Build MIPS Binary / build (push) Failing after 3s
2026-02-08 16:29:33 +03:00
spinline
b2a60d3d1e cleanup: kullanılmayan get_vapid_public_key fonksiyonu kaldırıldı
Some checks failed
Build MIPS Binary / build (push) Failing after 1m6s
2026-02-08 16:26:16 +03:00
spinline
520903fa3f perf: push bildirimleri paralel gönderim ve env var önbelleğe alma ile optimize edildi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 16:25:44 +03:00
spinline
c45f2f50e9 fix: ARM64 build hatası için wasm-opt versiyonu v117 olarak güncellendi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 16:25:02 +03:00
spinline
791eabe9bd fix: SQLite deadlock ve busy_timeout yönetimi iyileştirildi
Some checks failed
Build MIPS Binary / build (push) Failing after 1m2s
2026-02-08 16:20:55 +03:00
spinline
12f93dd640 perf: Trunk WASM optimizasyonu aktif edildi (data-wasm-opt=0 kaldırıldı)
Some checks failed
Build MIPS Binary / build (push) Failing after 1m2s
2026-02-08 16:18:50 +03:00
spinline
7306db8c2f fix: torrent diff algoritması hash tabanlı hale getirilerek sıralama bağımlılığı kaldırıldı
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 16:17:30 +03:00
spinline
ce0ecd62af fix: index.html yükleme ekranına zaman aşımı (15sn) ve hata mesajı eklendi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 16:13:20 +03:00
spinline
f2379b67d8 docs: main.rs içindeki güncelliğini yitirmiş şifre güncelleme yorumu temizlendi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 16:11:18 +03:00
spinline
755f35c94c security: gerçek .env dosyası takipten çıkarıldı ve .env.example güncellendi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 16:07:26 +03:00
spinline
175cac953e fix: SSE bağlantısı sadece giriş yapıldıktan sonra başlatılacak şekilde düzeltildi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m23s
2026-02-08 15:57:24 +03:00
spinline
2c812fc4f6 fix: login rate limit 5 deneme ve 1 dakika bekleme olarak güncellendi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 15:56:07 +03:00
spinline
08df851970 feat: login rate limit için frontend uyarı mesajı ve IP bazlı limit aktif edildi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 15:54:54 +03:00
spinline
35faa6bfda test: global rate limit denemesi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m21s
2026-02-08 15:47:00 +03:00
spinline
328019e438 fix: login rate limit ayarları daha katı hale getirildi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m22s
2026-02-08 13:59:08 +03:00
spinline
4f1c6326fd feat: login sistemi için tower-governor ile IP bazlı rate limit eklendi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m21s
2026-02-08 13:48:04 +03:00
spinline
2e36c28c0d fix(frontend): replace unwrap() with expect() for better error messages
All checks were successful
Build MIPS Binary / build (push) Successful in 4m13s
- console_log::init_with_level() now uses expect()
- web_sys::window() now uses expect() with helpful message
- window.document() now uses expect()
- document.body() now uses expect()

This provides meaningful error messages if WASM initialization fails.
2026-02-08 05:41:07 +03:00
spinline
6530e20af2 perf(db): enable SQLite WAL mode and performance settings
All checks were successful
Build MIPS Binary / build (push) Successful in 4m14s
- PRAGMA journal_mode=WAL - concurrent reads while writing
- PRAGMA synchronous=NORMAL - faster than FULL, still safe
- PRAGMA busy_timeout=5000 - reduces database locked errors

Note: Existing databases should be deleted to enable WAL mode properly.
2026-02-08 05:34:06 +03:00
spinline
32f4946530 fix: show N/A for magnet link dates
All checks were successful
Build MIPS Binary / build (push) Successful in 4m14s
Magnet links don't have creation_date, so timestamp is 0.
Now shows 'N/A' instead of 01/01/1970 00:00
2026-02-08 05:28:14 +03:00
spinline
619951fa1c security: remove hardcoded VAPID keys fallback
All checks were successful
Build MIPS Binary / build (push) Successful in 4m14s
VAPID keys must now be set via environment variables or .env file.
This eliminates the security risk of having keys in source code.
2026-02-08 05:16:31 +03:00
spinline
6d45e6773f chore: add DATABASE_URL to .env
All checks were successful
Build MIPS Binary / build (push) Successful in 4m14s
2026-02-08 05:11:31 +03:00
spinline
2c8a2d5956 feat(db): add migrations system and push subscriptions persistence
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
- Add sqlx migration system with migrations/ directory
- Create 001_init.sql and 002_push_subscriptions.sql migration files
- Move from manual CREATE TABLE to version-controlled migrations
- Add push_subscriptions table with DB persistence
- PushSubscriptionStore now loads from DB on startup
- Add save/remove/get methods for push subscriptions in db.rs
- Move VAPID keys to .env file (with fallback to hardcoded values)
- Delete old vibetorrent.db and recreate with migrations
2026-02-08 05:10:57 +03:00
spinline
6acb299fbe fix(mobile): add type=button and remove overlay
All checks were successful
Build MIPS Binary / build (push) Successful in 4m11s
2026-02-08 04:37:16 +03:00
spinline
ab49c2ded5 fix(mobile): use pointerdown like statusbar
All checks were successful
Build MIPS Binary / build (push) Successful in 4m15s
2026-02-08 04:27:12 +03:00
spinline
e4957e930d fix(mobile): fix sort dropdown button events
All checks were successful
Build MIPS Binary / build (push) Successful in 4m14s
2026-02-08 04:17:08 +03:00
spinline
ad2c6dc56e feat(torrent): add date sorting and display
All checks were successful
Build MIPS Binary / build (push) Successful in 4m15s
- Sort torrents by added date (newest first) by default
- Add Date column to desktop table (after ETA)
- Add Date to mobile card view (grid-cols-3 -> grid-cols-4)
- Add Date option to mobile sort dropdown
- Display dates in DD/MM/YYYY HH:mm format
- Add chrono wasm-bindgen feature
2026-02-08 04:10:02 +03:00
32 changed files with 1792 additions and 1110 deletions

View File

@@ -26,23 +26,22 @@ jobs:
run: | run: |
cd frontend cd frontend
npm install 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
# Trunk'ın optimizasyonunu kapalı (0) tutuyoruz çünkü Cargo.toml'daki opt-level='z' zaten o işi yapıyor.
trunk build --release trunk build --release
- name: Build Backend (MIPS) - name: Build Backend (MIPS)
env: env:
# Ensure we are building a fully static binary # -s ve -w ile binary içindeki gereksiz tüm yükleri siliyoruz.
# -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 -C link-arg=-s -C link-arg=-w"
RUSTFLAGS: "-C target-feature=+crt-static -C link-self-contained=no -C link-arg=-msoft-float"
CFLAGS_mips_unknown_linux_musl: "-msoft-float" CFLAGS_mips_unknown_linux_musl: "-msoft-float"
run: | run: |
cd backend # Sadece gerekli özellikleri derliyoruz (Boyut tasarrufu için swagger kapalı)
cargo zigbuild --target mips-unknown-linux-musl --release -Z build-std=std,panic_abort cargo zigbuild -p backend --target mips-unknown-linux-musl --release -Z build-std=std,panic_abort --no-default-features --features push-notifications
file target/mips-unknown-linux-musl/release/backend
- name: Rename Binary - name: Create Release Assets
run: mv target/mips-unknown-linux-musl/release/backend target/mips-unknown-linux-musl/release/vibetorrent-mips run: |
mv target/mips-unknown-linux-musl/release/backend target/mips-unknown-linux-musl/release/vibetorrent-mips
- name: Generate Release Tag - name: Generate Release Tag
id: tag id: tag
@@ -56,8 +55,10 @@ jobs:
REPO="admin/vibetorrent" REPO="admin/vibetorrent"
API_URL="${{ gitea.server_url }}/api/v1" API_URL="${{ gitea.server_url }}/api/v1"
# Create release RELEASE_RESPONSE=$(curl -s -X POST "${API_URL}/repos/${REPO}/releases" \
RELEASE_RESPONSE=$(curl -s -X POST "${API_URL}/repos/${REPO}/releases" -H "Authorization: token ${RELEASE_TOKEN}" -H "Content-Type: application/json" -d "{ -H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG}\", \"tag_name\": \"${TAG}\",
\"name\": \"Release ${TAG}\", \"name\": \"Release ${TAG}\",
\"body\": \"Automated build from commit ${{ gitea.sha }}\", \"body\": \"Automated build from commit ${{ gitea.sha }}\",
@@ -66,15 +67,9 @@ jobs:
}") }")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id') 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 curl -s -X POST "${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=vibetorrent-mips" \
echo "Failed to create release:" -H "Authorization: token ${RELEASE_TOKEN}" \
echo "$RELEASE_RESPONSE" -H "Content-Type: application/octet-stream" \
exit 1 --data-binary @target/mips-unknown-linux-musl/release/vibetorrent-mips
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."

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ result.xml
frontend/dist frontend/dist
backend.log backend.log
.runner .runner
.env
backend/.env

346
Cargo.lock generated
View File

@@ -300,6 +300,7 @@ dependencies = [
"clap", "clap",
"dotenvy", "dotenvy",
"futures", "futures",
"governor",
"mime_guess", "mime_guess",
"openssl", "openssl",
"quick-xml", "quick-xml",
@@ -316,6 +317,7 @@ dependencies = [
"tokio-util", "tokio-util",
"tower 0.4.13", "tower 0.4.13",
"tower-http", "tower-http",
"tower_governor",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"utoipa", "utoipa",
@@ -564,6 +566,15 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "codee"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d3ad3122b0001c7f140cf4d605ef9a9e2c24d96ab0b4fb4347b76de2425f445"
dependencies = [
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "collection_literals" name = "collection_literals"
version = "1.0.3" version = "1.0.3"
@@ -788,6 +799,41 @@ version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8" checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8"
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.114",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "dashmap" name = "dashmap"
version = "5.5.3" version = "5.5.3"
@@ -801,12 +847,38 @@ dependencies = [
"parking_lot_core", "parking_lot_core",
] ]
[[package]]
name = "dashmap"
version = "6.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.10.0" version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "default-struct-builder"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0df63c21a4383f94bd5388564829423f35c316aed85dc4f8427aded372c7c0d"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "der" name = "der"
version = "0.4.5" version = "0.4.5"
@@ -1084,6 +1156,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.3.2" version = "0.3.2"
@@ -1108,11 +1186,23 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "forwarded-header-value"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
dependencies = [
"nonempty",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "frontend" name = "frontend"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1",
"chrono", "chrono",
"codee",
"console_error_panic_hook", "console_error_panic_hook",
"console_log", "console_log",
"futures", "futures",
@@ -1120,12 +1210,15 @@ dependencies = [
"gloo-timers", "gloo-timers",
"js-sys", "js-sys",
"leptos", "leptos",
"leptos-use",
"leptos_router", "leptos_router",
"log", "log",
"serde", "serde",
"serde-wasm-bindgen",
"serde_json", "serde_json",
"shared", "shared",
"tailwind_fuse", "tailwind_fuse",
"thiserror 2.0.18",
"uuid", "uuid",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
@@ -1214,6 +1307,12 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.31" version = "0.3.31"
@@ -1337,6 +1436,29 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "governor"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
dependencies = [
"cfg-if",
"dashmap 6.1.0",
"futures-sink",
"futures-timer",
"futures-util",
"getrandom 0.3.4",
"hashbrown 0.16.1",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.9.2",
"smallvec",
"spinning_top",
"web-time",
]
[[package]] [[package]]
name = "group" name = "group"
version = "0.13.0" version = "0.13.0"
@@ -1348,6 +1470,25 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http 1.4.0",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "half" name = "half"
version = "2.7.1" version = "2.7.1"
@@ -1373,7 +1514,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [ dependencies = [
"allocator-api2", "allocator-api2",
"equivalent", "equivalent",
"foldhash", "foldhash 0.1.5",
] ]
[[package]] [[package]]
@@ -1381,6 +1522,11 @@ name = "hashbrown"
version = "0.16.1" version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
[[package]] [[package]]
name = "hashlink" name = "hashlink"
@@ -1569,6 +1715,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http 1.4.0", "http 1.4.0",
"http-body 1.0.1", "http-body 1.0.1",
"httparse", "httparse",
@@ -1578,6 +1725,20 @@ dependencies = [
"pin-utils", "pin-utils",
"smallvec", "smallvec",
"tokio", "tokio",
"want",
]
[[package]]
name = "hyper-timeout"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [
"hyper 1.8.1",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
] ]
[[package]] [[package]]
@@ -1600,13 +1761,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"futures-util",
"http 1.4.0", "http 1.4.0",
"http-body 1.0.1", "http-body 1.0.1",
"hyper 1.8.1", "hyper 1.8.1",
"libc",
"pin-project-lite", "pin-project-lite",
"socket2 0.6.2",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -1714,6 +1880,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.1.0" version = "1.1.0"
@@ -1881,6 +2053,30 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "leptos-use"
version = "0.13.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "789bf9f4337e6ebd8f1b407e3f762fdc538d48dc145f9d1dce2338014b38f4dd"
dependencies = [
"cfg-if",
"chrono",
"codee",
"cookie",
"default-struct-builder",
"futures-util",
"gloo-timers",
"js-sys",
"lazy_static",
"leptos",
"paste",
"thiserror 1.0.69",
"unic-langid",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "leptos_config" name = "leptos_config"
version = "0.6.15" version = "0.6.15"
@@ -2229,6 +2425,18 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "nonempty"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -2700,6 +2908,21 @@ dependencies = [
"yansi", "yansi",
] ]
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.31.0" version = "0.31.0"
@@ -2806,6 +3029,15 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -3196,7 +3428,7 @@ dependencies = [
"bytes", "bytes",
"ciborium", "ciborium",
"const_format", "const_format",
"dashmap", "dashmap 5.5.3",
"futures", "futures",
"gloo-net 0.6.0", "gloo-net 0.6.0",
"http 1.4.0", "http 1.4.0",
@@ -3375,6 +3607,15 @@ dependencies = [
"lock_api", "lock_api",
] ]
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]] [[package]]
name = "spki" name = "spki"
version = "0.6.0" version = "0.6.0"
@@ -3784,6 +4025,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"serde_core",
"zerovec", "zerovec",
] ]
@@ -3917,6 +4159,35 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tonic"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"h2",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"hyper 1.8.1",
"hyper-timeout",
"hyper-util",
"percent-encoding",
"pin-project",
"socket2 0.6.2",
"sync_wrapper",
"tokio",
"tokio-stream",
"tower 0.5.3",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.4.13" version = "0.4.13"
@@ -3941,9 +4212,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"indexmap",
"pin-project-lite", "pin-project-lite",
"slab",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-util",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -3988,6 +4262,23 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tower_governor"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6"
dependencies = [
"axum",
"forwarded-header-value",
"governor",
"http 1.4.0",
"pin-project",
"thiserror 2.0.18",
"tonic",
"tower 0.5.3",
"tracing",
]
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
@@ -4099,6 +4390,24 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unic-langid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
dependencies = [
"unic-langid-impl",
]
[[package]]
name = "unic-langid-impl"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
dependencies = [
"tinystr",
]
[[package]] [[package]]
name = "unicase" name = "unicase"
version = "2.9.0" version = "2.9.0"
@@ -4404,6 +4713,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "whoami" name = "whoami"
version = "1.6.1" version = "1.6.1"
@@ -4414,6 +4733,22 @@ dependencies = [
"wasite", "wasite",
] ]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -4423,6 +4758,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -4824,6 +5165,7 @@ version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [ dependencies = [
"serde",
"yoke", "yoke",
"zerofrom", "zerofrom",
"zerovec-derive", "zerovec-derive",

View File

@@ -2,13 +2,19 @@
members = ["backend", "frontend", "shared"] members = ["backend", "frontend", "shared"]
resolver = "2" resolver = "2"
# Optimize for size (aggressive)
[profile.release] [profile.release]
# En küçük binary boyutu
opt-level = "z" 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 codegen-units = 1
# Hata izleme kodlarını atarak yer kazan
panic = "abort" panic = "abort"
# Sembolleri ve hata ayıklama bilgilerini kesin sil
strip = true strip = true
# Artık (incremental) build'i kapat ki optimizasyon tam olsun
incremental = false
[patch.crates-io] [patch.crates-io]
coarsetime = { path = "third_party/coarsetime" } coarsetime = { path = "third_party/coarsetime" }

View File

@@ -3,3 +3,12 @@ RTORRENT_SOCKET=/tmp/rtorrent.sock
# Backend Listen Port # Backend Listen Port
PORT=3000 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

View File

@@ -4,8 +4,9 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[features] [features]
default = ["push-notifications"] default = ["push-notifications", "swagger"]
push-notifications = ["web-push", "openssl"] push-notifications = ["web-push", "openssl"]
swagger = ["utoipa-swagger-ui"]
[dependencies] [dependencies]
axum = { version = "0.8", features = ["macros", "ws"] } axum = { version = "0.8", features = ["macros", "ws"] }
@@ -29,7 +30,7 @@ shared = { path = "../shared" }
thiserror = "2.0.18" thiserror = "2.0.18"
dotenvy = "0.15.7" dotenvy = "0.15.7"
utoipa = { version = "5.4.0", features = ["axum_extras"] } utoipa = { version = "5.4.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"], optional = true }
web-push = { version = "0.10", default-features = false, features = ["hyper-client"], optional = true } web-push = { version = "0.10", default-features = false, features = ["hyper-client"], optional = true }
base64 = "0.22" base64 = "0.22"
openssl = { version = "0.10", features = ["vendored"], optional = true } openssl = { version = "0.10", features = ["vendored"], optional = true }
@@ -39,3 +40,5 @@ axum-extra = { version = "0.10", features = ["cookie"] }
rand = "0.8" rand = "0.8"
anyhow = "1.0.101" anyhow = "1.0.101"
time = { version = "0.3.47", features = ["serde", "formatting", "parsing"] } time = { version = "0.3.47", features = ["serde", "formatting", "parsing"] }
tower_governor = "0.8.0"
governor = "0.10.4"

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

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

View File

@@ -1,6 +1,7 @@
use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite, Row}; use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite, Row, sqlite::SqliteConnectOptions};
use std::time::Duration; use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use std::str::FromStr;
#[derive(Clone)] #[derive(Clone)]
pub struct Db { pub struct Db {
@@ -9,42 +10,25 @@ pub struct Db {
impl Db { impl Db {
pub async fn new(db_url: &str) -> Result<Self> { 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() let pool = SqlitePoolOptions::new()
.max_connections(5) .max_connections(5)
.acquire_timeout(Duration::from_secs(3)) .acquire_timeout(Duration::from_secs(10))
.connect(db_url) .connect_with(options)
.await?; .await?;
let db = Self { pool }; let db = Self { pool };
db.init().await?; db.run_migrations().await?;
Ok(db) Ok(db)
} }
async fn init(&self) -> Result<()> { async fn run_migrations(&self) -> Result<()> {
// Create users table sqlx::migrate!("./migrations").run(&self.pool).await?;
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(()) Ok(())
} }
@@ -76,6 +60,7 @@ impl Db {
Ok(row.map(|r| r.get(0))) Ok(row.map(|r| r.get(0)))
} }
pub async fn has_users(&self) -> Result<bool> { pub async fn has_users(&self) -> Result<bool> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&self.pool) .fetch_one(&self.pool)
@@ -128,4 +113,36 @@ impl Db {
.await?; .await?;
Ok(()) 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)
}
} }

View File

@@ -1,3 +1,4 @@
use std::collections::HashMap;
use shared::{AppEvent, NotificationLevel, SystemNotification, Torrent, TorrentUpdate}; use shared::{AppEvent, NotificationLevel, SystemNotification, Torrent, TorrentUpdate};
#[derive(Debug)] #[derive(Debug)]
@@ -8,24 +9,32 @@ pub enum DiffResult {
} }
pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult { pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
// 1. Structural Check (Length or Order changed) // 1. Structural Check: Eğer torrent sayısı değişmişse (yeni eklenen veya silinen),
// şimdilik basitlik adına FullUpdate gönderiyoruz.
if old.len() != new.len() { if old.len() != new.len() {
return DiffResult::FullUpdate; return DiffResult::FullUpdate;
} }
for (i, t) in new.iter().enumerate() { // 2. Hash Set Karşılaştırması:
if old[i].hash != t.hash { // Sıralama değişmiş olabilir ama torrentler aynı mı?
let old_map: HashMap<&str, &Torrent> = old.iter().map(|t| (t.hash.as_str(), t)).collect();
// Eğer yeni listedeki bir hash eski listede yoksa, yapı değişmiş demektir.
for new_t in new {
if !old_map.contains_key(new_t.hash.as_str()) {
return DiffResult::FullUpdate; return DiffResult::FullUpdate;
} }
} }
// 2. Field Updates // 3. Alan Güncellemeleri (Partial Updates)
// Buraya geldiğimizde biliyoruz ki old ve new listelerindeki torrentler (hash olarak) aynı,
// sadece sıraları farklı olabilir veya içindeki veriler güncellenmiş olabilir.
let mut events = Vec::new(); let mut events = Vec::new();
for (i, new_t) in new.iter().enumerate() { for new_t in new {
let old_t = &old[i]; // old_map'ten ilgili torrente hash ile ulaşalım (sıradan bağımsız)
let old_t = old_map.get(new_t.hash.as_str()).unwrap();
// Initialize with all None
let mut update = TorrentUpdate { let mut update = TorrentUpdate {
hash: new_t.hash.clone(), hash: new_t.hash.clone(),
name: None, name: None,
@@ -42,7 +51,7 @@ pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
let mut has_changes = false; let mut has_changes = false;
// Compare fields // Alanları karşılaştır
if old_t.name != new_t.name { if old_t.name != new_t.name {
update.name = Some(new_t.name.clone()); update.name = Some(new_t.name.clone());
has_changes = true; has_changes = true;
@@ -63,7 +72,7 @@ pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
update.percent_complete = Some(new_t.percent_complete); update.percent_complete = Some(new_t.percent_complete);
has_changes = true; has_changes = true;
// Check for torrent completion: reached 100% // Torrent tamamlanma kontrolü
if old_t.percent_complete < 100.0 && new_t.percent_complete >= 100.0 { if old_t.percent_complete < 100.0 && new_t.percent_complete >= 100.0 {
tracing::info!("Torrent completed: {} ({})", new_t.name, new_t.hash); tracing::info!("Torrent completed: {} ({})", new_t.name, new_t.hash);
events.push(AppEvent::Notification(SystemNotification { events.push(AppEvent::Notification(SystemNotification {
@@ -84,7 +93,6 @@ pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
update.status = Some(new_t.status.clone()); update.status = Some(new_t.status.clone());
has_changes = true; has_changes = true;
// Log status changes for debugging
tracing::debug!( tracing::debug!(
"Torrent status changed: {} ({}) {:?} -> {:?}", "Torrent status changed: {} ({}) {:?} -> {:?}",
new_t.name, new_t.hash, old_t.status, new_t.status new_t.name, new_t.hash, old_t.status, new_t.status

View File

@@ -11,12 +11,10 @@ use axum::{
BoxError, BoxError,
}; };
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
use serde::Deserialize;
use shared::{ use shared::{
GlobalLimitRequest, SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest, TorrentFile, AddTorrentRequest, GlobalLimitRequest, SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest,
TorrentPeer, TorrentTracker, TorrentFile, TorrentPeer, TorrentTracker,
}; };
use utoipa::ToSchema;
pub mod auth; pub mod auth;
pub mod setup; pub mod setup;
@@ -25,13 +23,6 @@ pub mod setup;
#[folder = "../frontend/dist"] #[folder = "../frontend/dist"]
pub struct Asset; 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 { pub async fn static_handler(uri: Uri) -> impl IntoResponse {
let mut path = uri.path().trim_start_matches('/').to_string(); let mut path = uri.path().trim_start_matches('/').to_string();
@@ -690,8 +681,10 @@ pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
(status = 200, description = "VAPID public key", body = String) (status = 200, description = "VAPID public key", body = String)
) )
)] )]
pub async fn get_push_public_key_handler() -> impl IntoResponse { pub async fn get_push_public_key_handler(
let public_key = push::get_vapid_public_key(); State(state): State<AppState>,
) -> impl IntoResponse {
let public_key = state.push_store.get_public_key();
(StatusCode::OK, Json(serde_json::json!({ "publicKey": public_key }))).into_response() (StatusCode::OK, Json(serde_json::json!({ "publicKey": public_key }))).into_response()
} }

View File

@@ -3,6 +3,7 @@ mod diff;
mod handlers; mod handlers;
#[cfg(feature = "push-notifications")] #[cfg(feature = "push-notifications")]
mod push; mod push;
mod rate_limit;
mod scgi; mod scgi;
mod sse; mod sse;
mod xmlrpc; mod xmlrpc;
@@ -25,12 +26,15 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::{broadcast, watch}; use tokio::sync::{broadcast, watch};
use tower::ServiceBuilder; use tower::ServiceBuilder;
use tower_governor::GovernorLayer;
use tower_http::{ use tower_http::{
compression::{CompressionLayer, CompressionLevel}, compression::{CompressionLayer, CompressionLevel},
cors::CorsLayer, cors::CorsLayer,
trace::TraceLayer, trace::TraceLayer,
}; };
#[cfg(feature = "swagger")]
use utoipa::OpenApi; use utoipa::OpenApi;
#[cfg(feature = "swagger")]
use utoipa_swagger_ui::SwaggerUi; use utoipa_swagger_ui::SwaggerUi;
#[derive(Clone)] #[derive(Clone)]
@@ -41,6 +45,7 @@ pub struct AppState {
pub db: db::Db, pub db: db::Db,
#[cfg(feature = "push-notifications")] #[cfg(feature = "push-notifications")]
pub push_store: push::PushSubscriptionStore, pub push_store: push::PushSubscriptionStore,
pub notify_poll: Arc<tokio::sync::Notify>,
} }
async fn auth_middleware( async fn auth_middleware(
@@ -96,6 +101,7 @@ struct Args {
reset_password: Option<String>, reset_password: Option<String>,
} }
#[cfg(feature = "swagger")]
#[cfg(feature = "push-notifications")] #[cfg(feature = "push-notifications")]
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
@@ -120,7 +126,7 @@ struct Args {
), ),
components( components(
schemas( schemas(
handlers::AddTorrentRequest, shared::AddTorrentRequest,
shared::TorrentActionRequest, shared::TorrentActionRequest,
shared::Torrent, shared::Torrent,
shared::TorrentStatus, shared::TorrentStatus,
@@ -144,6 +150,7 @@ struct Args {
)] )]
struct ApiDoc; struct ApiDoc;
#[cfg(feature = "swagger")]
#[cfg(not(feature = "push-notifications"))] #[cfg(not(feature = "push-notifications"))]
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
@@ -166,7 +173,7 @@ struct ApiDoc;
), ),
components( components(
schemas( schemas(
handlers::AddTorrentRequest, shared::AddTorrentRequest,
shared::TorrentActionRequest, shared::TorrentActionRequest,
shared::Torrent, shared::Torrent,
shared::TorrentStatus, shared::TorrentStatus,
@@ -253,9 +260,7 @@ async fn main() {
} }
}; };
// Update in DB (using a direct query since db.rs doesn't have update_password yet) // Update in DB
// We should add `update_password` to db.rs for cleaner code, but for now direct query is fine or we can extend Db.
// Let's extend Db.rs first to be clean.
if let Err(e) = db.update_password(user_id, &password_hash).await { if let Err(e) = db.update_password(user_id, &password_hash).await {
tracing::error!("Failed to update password in DB: {}", e); tracing::error!("Failed to update password in DB: {}", e);
std::process::exit(1); std::process::exit(1);
@@ -320,13 +325,28 @@ async fn main() {
// Channel for Events (Diffs) // Channel for Events (Diffs)
let (event_bus, _) = broadcast::channel::<AppEvent>(1024); 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 { let app_state = AppState {
tx: tx.clone(), tx: tx.clone(),
event_bus: event_bus.clone(), event_bus: event_bus.clone(),
scgi_socket_path: args.socket.clone(), scgi_socket_path: args.socket.clone(),
db: db.clone(), db: db.clone(),
#[cfg(feature = "push-notifications")] #[cfg(feature = "push-notifications")]
push_store: push::PushSubscriptionStore::new(), push_store,
notify_poll: notify_poll.clone(),
}; };
// Spawn background task to poll rTorrent // Spawn background task to poll rTorrent
@@ -335,6 +355,7 @@ async fn main() {
let socket_path = args.socket.clone(); // Clone for background task let socket_path = args.socket.clone(); // Clone for background task
#[cfg(feature = "push-notifications")] #[cfg(feature = "push-notifications")]
let push_store_clone = app_state.push_store.clone(); let push_store_clone = app_state.push_store.clone();
let notify_poll_clone = notify_poll.clone();
tokio::spawn(async move { tokio::spawn(async move {
let client = xmlrpc::RtorrentClient::new(&socket_path); let client = xmlrpc::RtorrentClient::new(&socket_path);
@@ -343,6 +364,14 @@ async fn main() {
let mut backoff_duration = Duration::from_secs(1); let mut backoff_duration = Duration::from_secs(1);
loop { 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(30)
};
// 1. Fetch Torrents // 1. Fetch Torrents
let torrents_result = sse::fetch_torrents(&client).await; let torrents_result = sse::fetch_torrents(&client).await;
@@ -413,6 +442,14 @@ async fn main() {
} }
previous_torrents = new_torrents; 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) => { Err(e) => {
tracing::error!("Error fetching torrents in background: {}", e); tracing::error!("Error fetching torrents in background: {}", e);
@@ -433,29 +470,33 @@ async fn main() {
"Backoff: Sleeping for {:?} due to rTorrent error.", "Backoff: Sleeping for {:?} due to rTorrent error.",
backoff_duration backoff_duration
); );
tokio::time::sleep(backoff_duration).await;
} }
} }
// Handle Stats // Handle Stats
match stats_result { if let Ok(stats) = stats_result {
Ok(stats) => {
let _ = event_bus_tx.send(AppEvent::Stats(stats)); let _ = event_bus_tx.send(AppEvent::Stats(stats));
} }
Err(e) => {
tracing::warn!("Error fetching global stats: {}", e);
}
}
tokio::time::sleep(backoff_duration).await;
} }
}); });
let app = Router::new() let app = Router::new();
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
#[cfg(feature = "swagger")]
let app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));
// Setup & Auth Routes // Setup & Auth Routes
let app = app
.route("/api/setup/status", get(handlers::setup::get_setup_status_handler)) .route("/api/setup/status", get(handlers::setup::get_setup_status_handler))
.route("/api/setup", post(handlers::setup::setup_handler)) .route("/api/setup", post(handlers::setup::setup_handler))
.route("/api/auth/login", post(handlers::auth::login_handler)) .route(
"/api/auth/login",
post(handlers::auth::login_handler).layer(GovernorLayer::new(Arc::new(
rate_limit::get_login_rate_limit_config(),
))),
)
.route("/api/auth/logout", post(handlers::auth::logout_handler)) .route("/api/auth/logout", post(handlers::auth::logout_handler))
.route("/api/auth/check", get(handlers::auth::check_auth_handler)) .route("/api/auth/check", get(handlers::auth::check_auth_handler))
// App Routes // App Routes
@@ -524,7 +565,12 @@ async fn main() {
} }
}; };
tracing::info!("Backend listening on {}", addr); 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); tracing::error!("Server error: {}", e);
std::process::exit(1); std::process::exit(1);
} }

View File

@@ -5,11 +5,9 @@ use utoipa::ToSchema;
use web_push::{ use web_push::{
HyperWebPushClient, SubscriptionInfo, VapidSignatureBuilder, WebPushClient, WebPushMessageBuilder, HyperWebPushClient, SubscriptionInfo, VapidSignatureBuilder, WebPushClient, WebPushMessageBuilder,
}; };
use futures::StreamExt;
// VAPID keys - PRODUCTION'DA ENVIRONMENT VARIABLE'DAN ALINMALI! use crate::db::Db;
const VAPID_PUBLIC_KEY: &str = "BEdPj6XQR7MGzM28Nev9wokF5upHoydNDahouJbQ9ZdBJpEFAN1iNfANSEvY0ItasNY5zcvvqN_tjUt64Rfd0gU";
const VAPID_PRIVATE_KEY: &str = "aUcCYJ7kUd9UClCaWwad0IVgbYJ6svwl19MjSX7GH10";
const VAPID_EMAIL: &str = "mailto:admin@vibetorrent.app";
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PushSubscription { pub struct PushSubscription {
@@ -23,39 +21,107 @@ pub struct PushKeys {
pub auth: String, pub auth: String,
} }
/// In-memory store for push subscriptions #[derive(Clone)]
/// TODO: Replace with database in production pub struct VapidConfig {
#[derive(Default, Clone)] pub private_key: String,
pub public_key: String,
pub email: String,
}
#[derive(Clone)]
pub struct PushSubscriptionStore { pub struct PushSubscriptionStore {
db: Option<Db>,
subscriptions: Arc<RwLock<Vec<PushSubscription>>>, subscriptions: Arc<RwLock<Vec<PushSubscription>>>,
vapid_config: VapidConfig,
} }
impl PushSubscriptionStore { impl PushSubscriptionStore {
pub fn new() -> Self { 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 { Self {
db: None,
subscriptions: Arc::new(RwLock::new(Vec::new())), 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) { pub async fn add_subscription(&self, subscription: PushSubscription) {
// Add to memory
let mut subs = self.subscriptions.write().await; let mut subs = self.subscriptions.write().await;
// Remove duplicate endpoint if exists // Remove duplicate endpoint if exists
subs.retain(|s| s.endpoint != subscription.endpoint); subs.retain(|s| s.endpoint != subscription.endpoint);
subs.push(subscription.clone());
subs.push(subscription);
tracing::info!("Added push subscription. Total: {}", subs.len()); 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) { pub async fn remove_subscription(&self, endpoint: &str) {
// Remove from memory
let mut subs = self.subscriptions.write().await; let mut subs = self.subscriptions.write().await;
subs.retain(|s| s.endpoint != endpoint); subs.retain(|s| s.endpoint != endpoint);
tracing::info!("Removed push subscription. Total: {}", subs.len()); 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> { pub async fn get_all_subscriptions(&self) -> Vec<PushSubscription> {
self.subscriptions.read().await.clone() self.subscriptions.read().await.clone()
} }
pub fn get_public_key(&self) -> &str {
&self.vapid_config.public_key
}
} }
/// Send push notification to all subscribed clients /// Send push notification to all subscribed clients
@@ -81,9 +147,18 @@ pub async fn send_push_notification(
"tag": "vibetorrent" "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 { // 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();
async move {
let subscription_info = SubscriptionInfo { let subscription_info = SubscriptionInfo {
endpoint: subscription.endpoint.clone(), endpoint: subscription.endpoint.clone(),
keys: web_push::SubscriptionKeys { keys: web_push::SubscriptionKeys {
@@ -92,36 +167,48 @@ pub async fn send_push_notification(
}, },
}; };
let mut sig_builder = VapidSignatureBuilder::from_base64( let sig_res = VapidSignatureBuilder::from_base64(
VAPID_PRIVATE_KEY, &vapid_config.private_key,
web_push::URL_SAFE_NO_PAD, web_push::URL_SAFE_NO_PAD,
&subscription_info, &subscription_info,
)?; );
sig_builder.add_claim("sub", VAPID_EMAIL); match sig_res {
sig_builder.add_claim("aud", subscription.endpoint.clone()); Ok(mut sig_builder) => {
let signature = sig_builder.build()?; 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); let mut builder = WebPushMessageBuilder::new(&subscription_info);
builder.set_vapid_signature(signature); builder.set_vapid_signature(signature);
let payload_str = payload.to_string();
builder.set_payload(web_push::ContentEncoding::Aes128Gcm, payload_str.as_bytes()); builder.set_payload(web_push::ContentEncoding::Aes128Gcm, payload_str.as_bytes());
match client.send(builder.build()?).await { match builder.build() {
Ok(msg) => {
match client.send(msg).await {
Ok(_) => { Ok(_) => {
tracing::debug!("Push notification sent to: {}", subscription.endpoint); tracing::debug!("Push notification sent to: {}", subscription.endpoint);
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to send push notification: {}", e); tracing::error!("Failed to send push notification to {}: {}", subscription.endpoint, e);
// TODO: Remove invalid subscriptions
} }
} }
} }
Err(e) => tracing::error!("Failed to build push message: {}", e),
}
}
Err(e) => tracing::error!("Failed to build VAPID signature: {}", e),
}
}
Err(e) => tracing::error!("Failed to create VAPID signature builder: {}", e),
}
}
})
.await;
Ok(()) Ok(())
}
pub fn get_vapid_public_key() -> &'static str { }
VAPID_PUBLIC_KEY
}

16
backend/src/rate_limit.rs Normal file
View File

@@ -0,0 +1,16 @@
use governor::clock::QuantaInstant;
use governor::middleware::NoOpMiddleware;
use tower_governor::governor::GovernorConfig;
use tower_governor::governor::GovernorConfigBuilder;
use tower_governor::key_extractor::SmartIpKeyExtractor;
pub fn get_login_rate_limit_config() -> GovernorConfig<SmartIpKeyExtractor, NoOpMiddleware<QuantaInstant>> {
// 5 yanlış denemeden sonra bloklanır.
// Her yeni hak için 60 saniye (1 dakika) bekleme süresi.
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_second(60)
.burst_size(5)
.finish()
.unwrap()
}

View File

@@ -11,6 +11,8 @@ pub enum ScgiError {
#[allow(dead_code)] #[allow(dead_code)]
#[error("Protocol Error: {0}")] #[error("Protocol Error: {0}")]
Protocol(String), Protocol(String),
#[error("Timeout: SCGI request took too long")]
Timeout,
} }
pub struct ScgiRequest { pub struct ScgiRequest {
@@ -78,20 +80,30 @@ impl ScgiRequest {
} }
pub async fn send_request(socket_path: &str, request: ScgiRequest) -> Result<Bytes, ScgiError> { pub async fn send_request(socket_path: &str, request: ScgiRequest) -> Result<Bytes, ScgiError> {
let perform_request = async {
let mut stream = UnixStream::connect(socket_path).await?; let mut stream = UnixStream::connect(socket_path).await?;
let data = request.encode(); let data = request.encode();
stream.write_all(&data).await?; stream.write_all(&data).await?;
let mut response = Vec::new(); let mut response = Vec::new();
stream.read_to_end(&mut response).await?; stream.read_to_end(&mut response).await?;
Ok::<Vec<u8>, std::io::Error>(response)
};
let response = tokio::time::timeout(std::time::Duration::from_secs(10), perform_request)
.await
.map_err(|_| ScgiError::Timeout)??;
let double_newline = b"\r\n\r\n"; let double_newline = b"\r\n\r\n";
if let Some(pos) = response let mut response_vec = response;
if let Some(pos) = response_vec
.windows(double_newline.len()) .windows(double_newline.len())
.position(|window| window == double_newline) .position(|window| window == double_newline)
{ {
Ok(Bytes::from(response.split_off(pos + double_newline.len()))) Ok(Bytes::from(
response_vec.split_off(pos + double_newline.len()),
))
} else { } else {
Ok(Bytes::from(response)) Ok(Bytes::from(response_vec))
} }
} }

View File

@@ -195,6 +195,9 @@ pub async fn fetch_global_stats(client: &RtorrentClient) -> Result<GlobalStats,
pub async fn sse_handler( pub async fn sse_handler(
State(state): State<AppState>, State(state): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> { ) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
// 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) // Get initial value synchronously (from the watch channel's current state)
let initial_rx = state.tx.subscribe(); let initial_rx = state.tx.subscribe();
let initial_torrents = initial_rx.borrow().clone(); let initial_torrents = initial_rx.borrow().clone();

View File

@@ -20,6 +20,8 @@ RUN apt-get update && apt-get install -y \
jq \ jq \
# Needed for some crate compilations # Needed for some crate compilations
protobuf-compiler \ protobuf-compiler \
# Install binaryen to have wasm-opt available system-wide
binaryen \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# 2. Install Node.js v20 (Manual install to support multi-arch cleanly) # 2. Install Node.js v20 (Manual install to support multi-arch cleanly)
@@ -70,7 +72,7 @@ RUN . "$HOME/.cargo/env" && \
ARCH=$(dpkg --print-architecture) && \ ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then TRUNK_ARCH="x86_64-unknown-linux-gnu"; \ if [ "$ARCH" = "amd64" ]; then TRUNK_ARCH="x86_64-unknown-linux-gnu"; \
elif [ "$ARCH" = "arm64" ]; then TRUNK_ARCH="aarch64-unknown-linux-gnu"; fi && \ 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 && \ chmod +x /root/.cargo/bin/trunk && \
# Install wasm-bindgen-cli (Compiling from source to avoid glibc issues, doing it ONCE here) # Install wasm-bindgen-cli (Compiling from source to avoid glibc issues, doing it ONCE here)
cargo install wasm-bindgen-cli --version 0.2.108 cargo install wasm-bindgen-cli --version 0.2.108

View File

@@ -21,34 +21,13 @@ wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
uuid = { version = "1", features = ["v4", "js"] } uuid = { version = "1", features = ["v4", "js"] }
futures = "0.3" futures = "0.3"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }
web-sys = { version = "0.3", features = [ 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"] }
"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" } shared = { path = "../shared" }
tailwind_fuse = "0.3.2" tailwind_fuse = "0.3.2"
js-sys = "0.3.85" js-sys = "0.3.85"
base64 = "0.22.1"
serde-wasm-bindgen = "0.6.5"
leptos-use = "0.13"
codee = "0.2"
thiserror = "2.0"

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/> />
<title>VibeTorrent</title> <title>VibeTorrent</title>
@@ -81,17 +81,20 @@
</script> </script>
</head> </head>
<body> <body style="cursor: pointer;">
<div <div
id="app-loading" id="app-loading"
style=" style="
display: flex; display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100vh; height: 100vh;
font-family: sans-serif;
" "
> >
<div <div
id="app-loading-spinner"
style=" style="
width: 40px; width: 40px;
height: 40px; height: 40px;
@@ -102,6 +105,32 @@
opacity: 0.5; opacity: 0.5;
" "
></div> ></div>
<div
id="app-loading-error"
style="display: none; text-align: center; margin-top: 20px; padding: 0 20px"
>
<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>
</div> </div>
<style> <style>
@keyframes spin { @keyframes spin {
@@ -113,10 +142,62 @@
body.app-loaded #app-loading { body.app-loaded #app-loading {
display: none !important; 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> </style>
<script>
// App loading timeout handler
(function () {
var timeout = setTimeout(function () {
if (!document.body.classList.contains("app-loaded")) {
var spinner = document.getElementById("app-loading-spinner");
var error = document.getElementById("app-loading-error");
if (spinner) spinner.style.display = "none";
if (error) error.style.display = "block";
}
}, 15000); // 15 seconds timeout
// Clean up timeout if app loads
var observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (
mutation.attributeName === "class" &&
document.body.classList.contains("app-loaded")
) {
clearTimeout(timeout);
observer.disconnect();
}
});
});
observer.observe(document.body, { attributes: true });
})();
</script>
<!-- Service Worker Registration & PWA Setup --> <!-- Service Worker Registration & PWA Setup -->
<script> <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) { if ("serviceWorker" in navigator) {
window.addEventListener("load", () => { window.addEventListener("load", () => {
navigator.serviceWorker navigator.serviceWorker

243
frontend/src/api/mod.rs Normal file
View File

@@ -0,0 +1,243 @@
use gloo_net::http::Request;
use shared::{AddTorrentRequest, TorrentActionRequest};
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,
}
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> {
Request::post(&format!("{}/settings/global-limits", base_url()))
.json(req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
}
pub mod push {
use super::*;
use crate::store::PushSubscriptionData;
pub async fn get_public_key() -> Result<String, ApiError> {
let resp = Request::get(&format!("{}/push/public-key", base_url()))
.send()
.await
.map_err(|_| ApiError::Network)?;
let key = resp.text().await.map_err(|_| ApiError::Network)?;
Ok(key)
}
pub async fn subscribe(req: &PushSubscriptionData) -> Result<(), ApiError> {
Request::post(&format!("{}/push/subscribe", base_url()))
.json(req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
}
pub mod torrent {
use super::*;
pub async fn add(uri: &str) -> Result<(), ApiError> {
let req = AddTorrentRequest {
uri: uri.to_string(),
};
Request::post(&format!("{}/torrents/add", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
pub async fn action(hash: &str, action: &str) -> Result<(), ApiError> {
let req = TorrentActionRequest {
hash: hash.to_string(),
action: action.to_string(),
};
Request::post(&format!("{}/torrents/action", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
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> {
use shared::SetLabelRequest;
let req = SetLabelRequest {
hash: hash.to_string(),
label: label.to_string(),
};
Request::post(&format!("{}/torrents/set_label", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
pub async fn set_priority(hash: &str, file_index: u32, priority: u8) -> Result<(), ApiError> {
use shared::SetFilePriorityRequest;
let req = SetFilePriorityRequest {
hash: hash.to_string(),
file_index,
priority,
};
Request::post(&format!("{}/torrents/set_priority", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
}

View File

@@ -3,40 +3,24 @@ use crate::components::toast::ToastContainer;
use crate::components::torrent::table::TorrentTable; use crate::components::torrent::table::TorrentTable;
use crate::components::auth::login::Login; use crate::components::auth::login::Login;
use crate::components::auth::setup::Setup; use crate::components::auth::setup::Setup;
use crate::api;
use leptos::*; use leptos::*;
use leptos_router::*; use leptos_router::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct SetupStatus {
completed: bool,
}
#[derive(Deserialize)]
struct UserResponse {
username: String,
}
#[component] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
crate::store::provide_torrent_store(); crate::store::provide_torrent_store();
// Auth State
let (is_loading, set_is_loading) = create_signal(true); let (is_loading, set_is_loading) = create_signal(true);
let (is_authenticated, set_is_authenticated) = create_signal(false); let (is_authenticated, set_is_authenticated) = create_signal(false);
// Check Auth & Setup Status on load
create_effect(move |_| { create_effect(move |_| {
spawn_local(async move { spawn_local(async move {
logging::log!("App initialization started..."); logging::log!("App initialization started...");
// 1. Check Setup Status let setup_res = api::setup::get_status().await;
let setup_res = gloo_net::http::Request::get("/api/setup/status").send().await;
match setup_res { match setup_res {
Ok(resp) => {
if resp.ok() {
match resp.json::<SetupStatus>().await {
Ok(status) => { Ok(status) => {
if !status.completed { if !status.completed {
logging::log!("Setup not completed, redirecting to /setup"); logging::log!("Setup not completed, redirecting to /setup");
@@ -46,23 +30,16 @@ pub fn App() -> impl IntoView {
return; return;
} }
} }
Err(e) => logging::error!("Failed to parse setup status: {}", e), Err(e) => logging::error!("Failed to get setup status: {:?}", e),
}
}
}
Err(e) => logging::error!("Network error checking setup status: {}", e),
} }
// 2. Check Auth Status let auth_res = api::auth::check_auth().await;
let auth_res = gloo_net::http::Request::get("/api/auth/check").send().await;
match auth_res { match auth_res {
Ok(resp) => { Ok(true) => {
if resp.status() == 200 {
logging::log!("Authenticated!"); logging::log!("Authenticated!");
// Parse user info if let Ok(user_info) = api::auth::get_user().await {
if let Ok(user_info) = resp.json::<UserResponse>().await {
if let Some(store) = use_context::<crate::store::TorrentStore>() { if let Some(store) = use_context::<crate::store::TorrentStore>() {
store.user.set(Some(user_info.username)); store.user.set(Some(user_info.username));
} }
@@ -70,33 +47,36 @@ pub fn App() -> impl IntoView {
set_is_authenticated.set(true); set_is_authenticated.set(true);
// If user is already authenticated but on login/setup page, redirect to home
let pathname = window().location().pathname().unwrap_or_default(); let pathname = window().location().pathname().unwrap_or_default();
if pathname == "/login" || pathname == "/setup" { if pathname == "/login" || pathname == "/setup" {
logging::log!("Already authenticated, redirecting to home"); logging::log!("Already authenticated, redirecting to home");
let navigate = use_navigate(); let navigate = use_navigate();
navigate("/", Default::default()); navigate("/", Default::default());
} }
} else { }
logging::log!("Not authenticated, redirecting to /login"); Ok(false) => {
let navigate = use_navigate(); logging::log!("Not authenticated");
let pathname = window().location().pathname().unwrap_or_default(); let pathname = window().location().pathname().unwrap_or_default();
if pathname != "/login" && pathname != "/setup" { if pathname != "/login" && pathname != "/setup" {
let navigate = use_navigate();
navigate("/login", Default::default()); navigate("/login", Default::default());
} }
} }
Err(e) => {
logging::error!("Auth check failed: {:?}", e);
let navigate = use_navigate();
navigate("/login", Default::default());
} }
Err(e) => logging::error!("Network error checking auth status: {}", e),
} }
set_is_loading.set(false); set_is_loading.set(false);
}); });
}); });
// Initialize push notifications (Only if authenticated)
create_effect(move |_| { create_effect(move |_| {
if is_authenticated.get() { if is_authenticated.get() {
spawn_local(async { spawn_local(async {
// ... (Push notification logic kept same, shortened for brevity in this replace)
// Wait a bit for service worker to be ready
gloo_timers::future::TimeoutFuture::new(2000).await; gloo_timers::future::TimeoutFuture::new(2000).await;
if crate::utils::platform::supports_push_notifications() && !crate::utils::platform::is_safari() { if crate::utils::platform::supports_push_notifications() && !crate::utils::platform::is_safari() {

View File

@@ -1,12 +1,5 @@
use leptos::*; use leptos::*;
use serde::Serialize; use crate::api;
#[derive(Serialize)]
struct LoginRequest {
username: String,
password: String,
remember_me: bool,
}
#[component] #[component]
pub fn Login() -> impl IntoView { pub fn Login() -> impl IntoView {
@@ -23,33 +16,31 @@ pub fn Login() -> impl IntoView {
logging::log!("Attempting login for user: {}", username.get()); logging::log!("Attempting login for user: {}", username.get());
let username = username.get();
let password = password.get();
let remember_me = remember_me.get();
spawn_local(async move { spawn_local(async move {
let req = LoginRequest { match api::auth::login(&username, &password, remember_me).await {
username: username.get(), Ok(_) => {
password: password.get(),
remember_me: remember_me.get(),
};
let client = gloo_net::http::Request::post("/api/auth/login")
.json(&req)
.expect("Failed to create request");
match client.send().await {
Ok(resp) => {
logging::log!("Login response status: {}", resp.status());
if resp.ok() {
logging::log!("Login successful, redirecting..."); logging::log!("Login successful, redirecting...");
// Force a full reload to re-run auth checks in App.rs
let _ = window().location().set_href("/"); let _ = window().location().set_href("/");
} else {
let text = resp.text().await.unwrap_or_default();
logging::error!("Login failed: {}", text);
set_error.set(Some("Kullanıcı adı veya şifre hatalı".to_string()));
}
} }
Err(e) => { Err(e) => {
logging::error!("Network error: {}", e); logging::error!("Login failed: {:?}", e);
set_error.set(Some("Bağlantı hatası".to_string())); let msg = match e {
crate::api::ApiError::RateLimited => {
"Çok fazla başarısız deneme yaptınız. Lütfen bir süre bekleyip tekrar deneyin.".to_string()
}
crate::api::ApiError::Unauthorized | crate::api::ApiError::LoginFailed => {
"Kullanıcı adı veya şifre hatalı".to_string()
}
crate::api::ApiError::Network => {
"Bağlantı hatası".to_string()
}
_ => "Bir hata oluştu".to_string()
};
set_error.set(Some(msg));
} }
} }
set_loading.set(false); set_loading.set(false);

View File

@@ -1,11 +1,5 @@
use leptos::*; use leptos::*;
use serde::Serialize; use crate::api;
#[derive(Serialize)]
struct SetupRequest {
username: String,
password: String,
}
#[component] #[component]
pub fn Setup() -> impl IntoView { pub fn Setup() -> impl IntoView {
@@ -35,29 +29,18 @@ pub fn Setup() -> impl IntoView {
return; return;
} }
let username = username.get();
let password = pass;
spawn_local(async move { spawn_local(async move {
let req = SetupRequest { match api::setup::setup(&username, &password).await {
username: username.get(), Ok(_) => {
password: pass, logging::log!("Setup completed successfully, redirecting...");
};
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 home after setup (auto-login handled by backend)
// Full reload to ensure auth state is refreshed
let _ = window().location().set_href("/"); let _ = window().location().set_href("/");
} else {
let text = resp.text().await.unwrap_or_default();
set_error.set(Some(format!("Hata: {}", text)));
} }
} Err(e) => {
Err(_) => { logging::error!("Setup failed: {:?}", e);
set_error.set(Some("Bağlantı hatası".to_string())); set_error.set(Some("Kurulum başarısız oldu".to_string()));
} }
} }
set_loading.set(false); set_loading.set(false);

View File

@@ -1,4 +1,5 @@
use leptos::*; use leptos::*;
use leptos_use::on_click_outside;
#[component] #[component]
pub fn ContextMenu( pub fn ContextMenu(
@@ -8,6 +9,10 @@ pub fn ContextMenu(
on_close: Callback<()>, on_close: Callback<()>,
on_action: Callback<(String, String)>, // (Action, Hash) on_action: Callback<(String, String)>, // (Action, Hash)
) -> impl IntoView { ) -> impl IntoView {
let container_ref = create_node_ref::<html::Div>();
let _ = on_click_outside(container_ref, move |_| on_close.call(()));
let handle_action = move |action: &str| { let handle_action = move |action: &str| {
let hash = torrent_hash.clone(); let hash = torrent_hash.clone();
let action_str = action.to_string(); let action_str = action.to_string();
@@ -22,16 +27,8 @@ pub fn ContextMenu(
} }
view! { 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>
<div <div
node_ref=container_ref
class="fixed z-[100] min-w-[200px] animate-in fade-in zoom-in-95 duration-100" 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) style=format!("left: {}px; top: {}px", position.0, position.1)
on:contextmenu=move |e| e.prevent_default() on:contextmenu=move |e| e.prevent_default()

View File

@@ -1,56 +1,52 @@
use leptos::wasm_bindgen::JsCast; use leptos::wasm_bindgen::JsCast;
use leptos::*; use leptos::*;
use crate::api;
#[component] #[component]
pub fn Sidebar() -> impl IntoView { pub fn Sidebar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let total_count = move || store.torrents.get().len(); let total_count = move || store.torrents.with(|map| map.len());
let downloading_count = move || { let downloading_count = move || {
store store.torrents.with(|map| {
.torrents map.values()
.get()
.iter()
.filter(|t| t.status == shared::TorrentStatus::Downloading) .filter(|t| t.status == shared::TorrentStatus::Downloading)
.count() .count()
})
}; };
let seeding_count = move || { let seeding_count = move || {
store store.torrents.with(|map| {
.torrents map.values()
.get()
.iter()
.filter(|t| t.status == shared::TorrentStatus::Seeding) .filter(|t| t.status == shared::TorrentStatus::Seeding)
.count() .count()
})
}; };
let completed_count = move || { let completed_count = move || {
store store.torrents.with(|map| {
.torrents map.values()
.get()
.iter()
.filter(|t| { .filter(|t| {
t.status == shared::TorrentStatus::Seeding t.status == shared::TorrentStatus::Seeding
|| (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0) || (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0)
}) })
.count() .count()
})
}; };
let paused_count = move || { let paused_count = move || {
store store.torrents.with(|map| {
.torrents map.values()
.get()
.iter()
.filter(|t| t.status == shared::TorrentStatus::Paused) .filter(|t| t.status == shared::TorrentStatus::Paused)
.count() .count()
})
}; };
let inactive_count = move || { let inactive_count = move || {
store store.torrents.with(|map| {
.torrents map.values()
.get()
.iter()
.filter(|t| { .filter(|t| {
t.status == shared::TorrentStatus::Paused t.status == shared::TorrentStatus::Paused
|| t.status == shared::TorrentStatus::Error || t.status == shared::TorrentStatus::Error
}) })
.count() .count()
})
}; };
let close_drawer = move || { let close_drawer = move || {
@@ -76,13 +72,9 @@ pub fn Sidebar() -> impl IntoView {
let handle_logout = move |_| { let handle_logout = move |_| {
spawn_local(async move { spawn_local(async move {
let client = gloo_net::http::Request::post("/api/auth/logout"); if api::auth::logout().await.is_ok() {
if let Ok(resp) = client.send().await {
if resp.ok() {
// Force full reload to clear state
let _ = window().location().set_href("/login"); let _ = window().location().set_href("/login");
} }
}
}); });
}; };

View File

@@ -1,5 +1,8 @@
use leptos::*; use leptos::*;
use leptos_use::storage::use_local_storage;
use codee::string::FromToStringCodec;
use shared::GlobalLimitRequest; use shared::GlobalLimitRequest;
use crate::api;
fn format_bytes(bytes: i64) -> String { fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"]; const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
@@ -26,34 +29,19 @@ pub fn StatusBar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let stats = store.global_stats; let stats = store.global_stats;
let initial_theme = if let Some(win) = web_sys::window() { // Use leptos-use for reactive localStorage management
if let Some(doc) = win.document() { let (current_theme, set_current_theme, _) = use_local_storage::<String, FromToStringCodec>("vibetorrent_theme");
doc.document_element()
.and_then(|el| el.get_attribute("data-theme")) // Initialize with default if empty
.unwrap_or_else(|| "dark".to_string()) if current_theme.get_untracked().is_empty() {
} else { set_current_theme.set("dark".to_string());
"dark".to_string()
} }
} else {
"dark".to_string()
};
let (current_theme, set_current_theme) = create_signal(initial_theme);
// Automatically sync theme to document attribute
create_effect(move |_| { create_effect(move |_| {
if let Some(win) = web_sys::window() { let theme = current_theme.get().to_lowercase();
if let Some(storage) = win.local_storage().ok().flatten() { if let Some(doc) = document().document_element() {
if let Ok(Some(stored_theme)) = storage.get_item("vibetorrent_theme") { let _ = doc.set_attribute("data-theme", &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);
}
}
}
} }
}); });
@@ -73,8 +61,7 @@ pub fn StatusBar() -> impl IntoView {
let limit_type = limit_type.to_string(); let limit_type = limit_type.to_string();
logging::log!("Setting {} limit to {}", limit_type, val); logging::log!("Setting {} limit to {}", limit_type, val);
spawn_local(async move { let req = if limit_type == "down" {
let req_body = if limit_type == "down" {
GlobalLimitRequest { GlobalLimitRequest {
max_download_rate: Some(val), max_download_rate: Some(val),
max_upload_rate: None, max_upload_rate: None,
@@ -86,68 +73,33 @@ pub fn StatusBar() -> impl IntoView {
} }
}; };
let client = spawn_local(async move {
gloo_net::http::Request::post("/api/settings/global-limits").json(&req_body); if let Err(e) = api::settings::set_global_limits(&req).await {
logging::error!("Failed to set limit: {:?}", e);
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 { } else {
logging::log!("Limit set successfully"); 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 // Refs for click outside detection (Handled globally via JS in index.html for better iOS support)
let (active_dropdown, set_active_dropdown) = create_signal(0u8); let down_details_ref = create_node_ref::<html::Details>();
let up_details_ref = create_node_ref::<html::Details>();
let theme_details_ref = create_node_ref::<html::Details>();
// Toggle a specific dropdown // Helper to close a details element
let toggle = move |id: u8| { let close_details = |node_ref: NodeRef<html::Details>| {
let current = active_dropdown.get_untracked(); if let Some(el) = node_ref.get_untracked() {
if current == id { el.set_open(false);
set_active_dropdown.set(0);
} else {
set_active_dropdown.set(id);
} }
}; };
// Close all dropdowns
let close_all = move || {
set_active_dropdown.set(0);
};
view! { view! {
// Transparent overlay to close dropdowns when clicking outside <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] cursor-pointer">
<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 --- // --- DOWNLOAD SPEED DROPDOWN ---
<div class="relative"> <details class="dropdown dropdown-top" node_ref=down_details_ref>
<div <summary class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
</svg> </svg>
@@ -157,12 +109,9 @@ pub fn StatusBar() -> impl IntoView {
{move || format!("(Limit: {})", format_speed(stats.get().down_limit.unwrap_or(0)))} {move || format!("(Limit: {})", format_speed(stats.get().down_limit.unwrap_or(0)))}
</span> </span>
</Show> </Show>
</div> </summary>
<ul <ul class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300">
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)| { limits.clone().into_iter().map(|(val, label)| {
let is_active = move || { let is_active = move || {
@@ -173,10 +122,9 @@ pub fn StatusBar() -> impl IntoView {
<li> <li>
<button <button
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" } 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| { on:click=move |_| {
e.stop_propagation();
set_limit("down", val); set_limit("down", val);
close_all(); close_details(down_details_ref);
} }
> >
{label} {label}
@@ -189,18 +137,11 @@ pub fn StatusBar() -> impl IntoView {
}).collect::<Vec<_>>() }).collect::<Vec<_>>()
} }
</ul> </ul>
</div> </details>
// --- UPLOAD SPEED DROPDOWN --- // --- UPLOAD SPEED DROPDOWN ---
<div class="relative"> <details class="dropdown dropdown-top" node_ref=up_details_ref>
<div <summary class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
</svg> </svg>
@@ -210,12 +151,9 @@ pub fn StatusBar() -> impl IntoView {
{move || format!("(Limit: {})", format_speed(stats.get().up_limit.unwrap_or(0)))} {move || format!("(Limit: {})", format_speed(stats.get().up_limit.unwrap_or(0)))}
</span> </span>
</Show> </Show>
</div> </summary>
<ul <ul class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300">
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)| { limits.clone().into_iter().map(|(val, label)| {
let is_active = move || { let is_active = move || {
@@ -226,10 +164,9 @@ pub fn StatusBar() -> impl IntoView {
<li> <li>
<button <button
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" } 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| { on:click=move |_| {
e.stop_propagation();
set_limit("up", val); set_limit("up", val);
close_all(); close_details(up_details_ref);
} }
> >
{label} {label}
@@ -242,27 +179,17 @@ pub fn StatusBar() -> impl IntoView {
}).collect::<Vec<_>>() }).collect::<Vec<_>>()
} }
</ul> </ul>
</div> </details>
<div class="ml-auto flex items-center gap-4"> <div class="ml-auto flex items-center gap-4">
<div class="relative"> <details class="dropdown dropdown-top dropdown-end" node_ref=theme_details_ref>
<div <summary class="btn btn-ghost btn-xs btn-square cursor-pointer outline-none list-none [&::-webkit-details-marker]:hidden">
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"> <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" /> <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> </svg>
</div> </summary>
<ul <ul class="dropdown-content 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">
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![ let themes = vec![
"light", "dark", "dim", "nord", "cupcake", "dracula", "cyberpunk", "emerald", "sunset", "abyss" "light", "dark", "dim", "nord", "cupcake", "dracula", "cyberpunk", "emerald", "sunset", "abyss"
@@ -272,18 +199,9 @@ pub fn StatusBar() -> impl IntoView {
<li> <li>
<button <button
class=move || if current_theme.get() == theme { "bg-primary/10 text-primary font-bold text-xs capitalize" } else { "text-xs capitalize" } 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| { on:click=move |_| {
e.stop_propagation();
set_current_theme.set(theme.to_string()); set_current_theme.set(theme.to_string());
if let Some(win) = web_sys::window() { close_details(theme_details_ref);
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} {theme}
@@ -293,7 +211,7 @@ pub fn StatusBar() -> impl IntoView {
}).collect::<Vec<_>>() }).collect::<Vec<_>>()
} }
</ul> </ul>
</div> </details>
<button <button
class="btn btn-ghost btn-xs btn-square" class="btn btn-ghost btn-xs btn-square"
title="Settings & Notification Permissions" title="Settings & Notification Permissions"

View File

@@ -1,6 +1,7 @@
use leptos::*; use leptos::*;
use leptos::html::Dialog; use leptos::html::Dialog;
use crate::store::{show_toast_with_signal, TorrentStore}; use crate::store::{show_toast_with_signal, TorrentStore};
use crate::api;
use shared::NotificationLevel; use shared::NotificationLevel;
@@ -17,7 +18,6 @@ pub fn AddTorrentModal(
let (is_loading, set_loading) = create_signal(false); let (is_loading, set_loading) = create_signal(false);
let (error_msg, set_error_msg) = create_signal(Option::<String>::None); let (error_msg, set_error_msg) = create_signal(Option::<String>::None);
// Effect to open the dialog when the component mounts/renders
create_effect(move |_| { create_effect(move |_| {
if let Some(dialog) = dialog_ref.get() { if let Some(dialog) = dialog_ref.get() {
let _ = dialog.show_modal(); let _ = dialog.show_modal();
@@ -35,18 +35,10 @@ pub fn AddTorrentModal(
set_loading.set(true); set_loading.set(true);
set_error_msg.set(None); set_error_msg.set(None);
let uri_val = uri_val;
spawn_local(async move { spawn_local(async move {
let req_body = serde_json::json!({ match api::torrent::add(&uri_val).await {
"uri": uri_val Ok(_) => {
});
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"); logging::log!("Torrent added successfully");
show_toast_with_signal(notifications, NotificationLevel::Success, "Torrent eklendi"); show_toast_with_signal(notifications, NotificationLevel::Success, "Torrent eklendi");
set_loading.set(false); set_loading.set(false);
@@ -54,27 +46,11 @@ pub fn AddTorrentModal(
dialog.close(); dialog.close();
} }
on_close.call(()); on_close.call(());
} else { }
let status = resp.status(); Err(e) => {
let text = resp.text().await.unwrap_or_default(); logging::error!("Failed to add torrent: {:?}", e);
logging::error!("Failed to add torrent: {} - {}", status, text);
show_toast_with_signal(notifications, NotificationLevel::Error, "Torrent eklenemedi"); show_toast_with_signal(notifications, NotificationLevel::Error, "Torrent eklenemedi");
set_error_msg.set(Some(format!("Error {}: {}", status, text))); set_error_msg.set(Some(format!("Error: {:?}", e)));
set_loading.set(false);
}
}
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);
}
}
}
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); set_loading.set(false);
} }
} }

View File

@@ -1,7 +1,7 @@
use leptos::*; use leptos::*;
use wasm_bindgen::closure::Closure; use leptos_use::{on_click_outside, use_timeout_fn};
use wasm_bindgen::JsCast;
use crate::store::{get_action_messages, show_toast_with_signal}; use crate::store::{get_action_messages, show_toast_with_signal};
use crate::api;
use shared::NotificationLevel; use shared::NotificationLevel;
fn format_bytes(bytes: i64) -> String { fn format_bytes(bytes: i64) -> String {
@@ -45,6 +45,17 @@ fn format_duration(seconds: i64) -> String {
} }
} }
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(),
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SortColumn { enum SortColumn {
Name, Name,
@@ -54,6 +65,7 @@ enum SortColumn {
DownSpeed, DownSpeed,
UpSpeed, UpSpeed,
ETA, ETA,
AddedDate,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -66,14 +78,14 @@ enum SortDirection {
pub fn TorrentTable() -> impl IntoView { pub fn TorrentTable() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let sort_col = create_rw_signal(SortColumn::Name); let sort_col = create_rw_signal(SortColumn::AddedDate);
let sort_dir = create_rw_signal(SortDirection::Ascending); let sort_dir = create_rw_signal(SortDirection::Descending);
let filtered_torrents = move || { // Get sorted and filtered hashes only
let mut torrents = store let filtered_hashes = move || {
.torrents store.torrents.with(|map| {
.get() let mut torrents: Vec<&shared::Torrent> = map
.into_iter() .values()
.filter(|t| { .filter(|t| {
let filter = store.filter.get(); let filter = store.filter.get();
let search = store.search_query.get().to_lowercase(); let search = store.search_query.get().to_lowercase();
@@ -90,8 +102,10 @@ pub fn TorrentTable() -> impl IntoView {
t.status == shared::TorrentStatus::Seeding t.status == shared::TorrentStatus::Seeding
|| (t.status == shared::TorrentStatus::Paused || (t.status == shared::TorrentStatus::Paused
&& t.percent_complete >= 100.0) && t.percent_complete >= 100.0)
} // Approximate }
crate::store::FilterStatus::Paused => t.status == shared::TorrentStatus::Paused, crate::store::FilterStatus::Paused => {
t.status == shared::TorrentStatus::Paused
}
crate::store::FilterStatus::Inactive => { crate::store::FilterStatus::Inactive => {
t.status == shared::TorrentStatus::Paused t.status == shared::TorrentStatus::Paused
|| t.status == shared::TorrentStatus::Error || t.status == shared::TorrentStatus::Error
@@ -107,7 +121,7 @@ pub fn TorrentTable() -> impl IntoView {
matches_filter && matches_search matches_filter && matches_search
}) })
.collect::<Vec<_>>(); .collect();
torrents.sort_by(|a, b| { torrents.sort_by(|a, b| {
let col = sort_col.get(); let col = sort_col.get();
@@ -127,6 +141,7 @@ pub fn TorrentTable() -> impl IntoView {
let b_eta = if b.eta <= 0 { i64::MAX } else { b.eta }; let b_eta = if b.eta <= 0 { i64::MAX } else { b.eta };
a_eta.cmp(&b_eta) a_eta.cmp(&b_eta)
} }
SortColumn::AddedDate => a.added_date.cmp(&b.added_date),
}; };
if dir == SortDirection::Descending { if dir == SortDirection::Descending {
cmp.reverse() cmp.reverse()
@@ -135,7 +150,8 @@ pub fn TorrentTable() -> impl IntoView {
} }
}); });
torrents torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
})
}; };
let handle_sort = move |col: SortColumn| { let handle_sort = move |col: SortColumn| {
@@ -152,8 +168,13 @@ pub fn TorrentTable() -> impl IntoView {
} }
}; };
// Signal-based sort dropdown for mobile // Refs for click outside detection
let (sort_open, set_sort_open) = create_signal(false); let sort_details_ref = create_node_ref::<html::Details>();
let _ = on_click_outside(sort_details_ref, move |_| {
if let Some(el) = sort_details_ref.get_untracked() {
el.set_open(false);
}
});
let sort_arrow = move |col: SortColumn| { let sort_arrow = move |col: SortColumn| {
if sort_col.get() == col { if sort_col.get() == col {
@@ -184,54 +205,33 @@ pub fn TorrentTable() -> impl IntoView {
let on_action = move |(action, hash): (String, String)| { let on_action = move |(action, hash): (String, String)| {
logging::log!("TorrentTable Action: {} on {}", action, hash); logging::log!("TorrentTable Action: {} on {}", action, hash);
// Note: Don't close menu here - ContextMenu's on_close handles it
// Closing here would dispose ContextMenu while still in callback chain
// Get action messages for toast (Clean Code: DRY)
let (success_msg, error_msg) = get_action_messages(&action); let (success_msg, error_msg) = get_action_messages(&action);
let success_msg = success_msg.to_string(); let success_msg = success_msg.to_string();
let error_msg = error_msg.to_string(); let error_msg = error_msg.to_string();
// Capture notifications signal before async (use_context unavailable in spawn_local)
let notifications = store.notifications; let notifications = store.notifications;
let hash = hash.clone();
let action = action.clone();
spawn_local(async move { spawn_local(async move {
let action_req = if action == "delete_with_data" { let result = match action.as_str() {
"delete_with_data" "delete" => api::torrent::delete(&hash).await,
} else { "delete_with_data" => api::torrent::delete_with_data(&hash).await,
&action "start" => api::torrent::start(&hash).await,
"stop" => api::torrent::stop(&hash).await,
_ => api::torrent::action(&hash, &action).await,
}; };
let req_body = shared::TorrentActionRequest { match result {
hash: hash.clone(), Ok(_) => {
action: action_req.to_string(),
};
let client = gloo_net::http::Request::post("/api/torrents/action").json(&req_body);
match client {
Ok(req) => match req.send().await {
Ok(resp) => {
if !resp.ok() {
logging::error!(
"Failed to execute action: {} {}",
resp.status(),
resp.status_text()
);
show_toast_with_signal(notifications, NotificationLevel::Error, error_msg);
} else {
logging::log!("Action {} executed successfully", action); logging::log!("Action {} executed successfully", action);
show_toast_with_signal(notifications, NotificationLevel::Success, success_msg); show_toast_with_signal(notifications, NotificationLevel::Success, success_msg);
} }
}
Err(e) => { Err(e) => {
logging::error!("Network error executing action: {}", e); logging::error!("Action failed: {:?}", e);
show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: Bağlantı hatası", error_msg)); show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: {:?}", error_msg, e));
}
},
Err(e) => {
logging::error!("Failed to serialize request: {}", e);
show_toast_with_signal(notifications, NotificationLevel::Error, error_msg);
} }
} }
}); });
@@ -256,7 +256,7 @@ pub fn TorrentTable() -> impl IntoView {
<div class="flex items-center">"Status" {move || sort_arrow(SortColumn::Status)}</div> <div class="flex items-center">"Status" {move || sort_arrow(SortColumn::Status)}</div>
</th> </th>
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::DownSpeed)> <th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
<div class="flex items-center">"Down Speed" {move || sort_arrow(SortColumn::DownSpeed)}</div> <div class="flex items-center">"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}</div>
</th> </th>
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::UpSpeed)> <th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
<div class="flex items-center">"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}</div> <div class="flex items-center">"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}</div>
@@ -264,98 +264,45 @@ pub fn TorrentTable() -> impl IntoView {
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::ETA)> <th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::ETA)>
<div class="flex items-center">"ETA" {move || sort_arrow(SortColumn::ETA)}</div> <div class="flex items-center">"ETA" {move || sort_arrow(SortColumn::ETA)}</div>
</th> </th>
<th class="w-32 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::AddedDate)>
<div class="flex items-center">"Date" {move || sort_arrow(SortColumn::AddedDate)}</div>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{move || filtered_torrents().into_iter().map(|t| { <For
let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" }; each=move || filtered_hashes()
let status_str = format!("{:?}", t.status); key=|hash| hash.clone()
let status_class = match t.status { children={
shared::TorrentStatus::Seeding => "text-success", let handle_context_menu = handle_context_menu.clone();
shared::TorrentStatus::Downloading => "text-primary", move |hash| {
shared::TorrentStatus::Paused => "text-warning",
shared::TorrentStatus::Error => "text-error",
_ => "text-base-content/50"
};
let t_hash = t.hash.clone();
let t_hash_click = t.hash.clone();
let is_selected_fn = move || {
selected_hash.get() == Some(t_hash.clone())
};
view! { view! {
<tr <TorrentRow
class=move || { hash=hash.clone()
let base = "hover border-b border-base-200 select-none"; selected_hash=selected_hash
if is_selected_fn() { set_selected_hash=set_selected_hash
format!("{} bg-primary/10", base) on_context_menu=handle_context_menu.clone()
} else { />
base.to_string()
} }
} }
on:contextmenu={
let t_hash = t_hash_click.clone();
move |e: web_sys::MouseEvent| handle_context_menu(e, t_hash.clone())
} }
on:click={ />
let t_hash = t_hash_click.clone();
move |_| set_selected_hash.set(Some(t_hash.clone()))
}
>
<td class="font-medium truncate max-w-xs" title={t.name.clone()}>
{t.name}
</td>
<td class="opacity-80 font-mono text-[11px]">{format_bytes(t.size)}</td>
<td>
<div class="flex items-center gap-2">
<progress class={format!("progress w-24 {}", progress_class)} value={t.percent_complete} max="100"></progress>
<span class="text-[10px] opacity-70">{format!("{:.1}%", t.percent_complete)}</span>
</div>
</td>
<td class={format!("text-[11px] font-medium {}", status_class)}>{status_str}</td>
<td class="text-right font-mono text-[11px] opacity-80 text-success">{format_speed(t.down_rate)}</td>
<td class="text-right font-mono text-[11px] opacity-80 text-primary">{format_speed(t.up_rate)}</td>
<td class="text-right font-mono text-[11px] opacity-80">{format_duration(t.eta)}</td>
</tr>
}
}).collect::<Vec<_>>()}
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="md:hidden flex flex-col h-full bg-base-200 relative"> <div class="md:hidden flex flex-col h-full bg-base-200 relative cursor-pointer">
// Transparent overlay to close sort dropdown <div class="px-3 py-2 border-b border-base-200 flex justify-between items-center bg-base-100/95 backdrop-blur z-10 shrink-0 cursor-default">
<Show when=move || sort_open.get()>
<div
class="fixed inset-0 z-[98] cursor-default"
on:pointerdown=move |_| set_sort_open.set(false)
></div>
</Show>
<div class="px-3 py-2 border-b border-base-200 flex justify-between items-center bg-base-100/95 backdrop-blur z-10 shrink-0">
<span class="text-xs font-bold opacity-50 uppercase tracking-wider">"Torrents"</span> <span class="text-xs font-bold opacity-50 uppercase tracking-wider">"Torrents"</span>
<div class="relative"> <details class="dropdown dropdown-end" node_ref=sort_details_ref>
<div <summary class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal list-none [&::-webkit-details-marker]:hidden cursor-pointer">
role="button" <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 pointer-events-none">
class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal"
on:pointerdown=move |e| {
e.stop_propagation();
let cur = sort_open.get_untracked();
set_sort_open.set(!cur);
}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" />
</svg> </svg>
"Sort" <span class="pointer-events-none">"Sort"</span>
</div> </summary>
<ul <ul class="dropdown-content z-[100] menu p-2 shadow bg-base-100 rounded-box w-48 mt-1 border border-base-200 text-xs cursor-default">
class="absolute top-full right-0 z-[100] menu p-2 shadow bg-base-100 rounded-box w-48 mt-1 border border-base-200 text-xs"
style=move || if sort_open.get() { "display: block" } else { "display: none" }
on:pointerdown=move |e| e.stop_propagation()
>
<li class="menu-title px-2 py-1 opacity-50 text-[10px] uppercase font-bold">"Sort By"</li> <li class="menu-title px-2 py-1 opacity-50 text-[10px] uppercase font-bold">"Sort By"</li>
{ {
let columns = vec![ let columns = vec![
@@ -363,9 +310,10 @@ pub fn TorrentTable() -> impl IntoView {
(SortColumn::Size, "Size"), (SortColumn::Size, "Size"),
(SortColumn::Progress, "Progress"), (SortColumn::Progress, "Progress"),
(SortColumn::Status, "Status"), (SortColumn::Status, "Status"),
(SortColumn::DownSpeed, "Down Speed"), (SortColumn::DownSpeed, "DL Speed"),
(SortColumn::UpSpeed, "Up Speed"), (SortColumn::UpSpeed, "Up Speed"),
(SortColumn::ETA, "ETA"), (SortColumn::ETA, "ETA"),
(SortColumn::AddedDate, "Date"),
]; ];
columns.into_iter().map(|(col, label)| { columns.into_iter().map(|(col, label)| {
@@ -375,11 +323,13 @@ pub fn TorrentTable() -> impl IntoView {
view! { view! {
<li> <li>
<button <button
type="button"
class=move || if is_active() { "bg-primary/10 text-primary font-bold flex justify-between" } else { "flex justify-between" } class=move || if is_active() { "bg-primary/10 text-primary font-bold flex justify-between" } else { "flex justify-between" }
on:pointerdown=move |e| { on:click=move |_| {
e.stop_propagation();
handle_sort(col); handle_sort(col);
set_sort_open.set(false); if let Some(el) = sort_details_ref.get_untracked() {
el.set_open(false);
}
} }
> >
{label} {label}
@@ -397,10 +347,154 @@ pub fn TorrentTable() -> impl IntoView {
}).collect::<Vec<_>>() }).collect::<Vec<_>>()
} }
</ul> </ul>
</details>
</div>
<div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3 cursor-pointer">
<For
each=move || filtered_hashes()
key=|hash| hash.clone()
children={
let handle_context_menu = handle_context_menu.clone();
move |hash| {
view! {
<TorrentCard
hash=hash.clone()
selected_hash=selected_hash
set_selected_hash=set_selected_hash
set_menu_position=set_menu_position
set_menu_visible=set_menu_visible
on_context_menu=handle_context_menu.clone()
/>
}
}
}
/>
</div> </div>
</div> </div>
<div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3"> {move || filtered_torrents().into_iter().map(|t| { <Show when=move || menu_visible.get() fallback=|| ()>
<crate::components::context_menu::ContextMenu
visible=true
position=menu_position.get()
torrent_hash=selected_hash.get().unwrap_or_default()
on_close=Callback::from(move |_| set_menu_visible.set(false))
on_action=Callback::from(on_action)
/>
</Show>
</div>
}
}
#[component]
fn TorrentRow(
hash: String,
selected_hash: ReadSignal<Option<String>>,
set_selected_hash: WriteSignal<Option<String>>,
on_context_menu: impl Fn(web_sys::MouseEvent, String) + 'static + Clone,
) -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let h = hash.clone();
// Memoized access to the specific torrent data.
// This only re-renders the row if this specific torrent actually changes.
let torrent = create_memo(move |_| {
store.torrents.with(|map| map.get(&h).cloned())
});
view! {
<Show when=move || torrent.get().is_some() fallback=|| ()>
{
let on_context_menu = on_context_menu.clone();
let hash = hash.clone();
move || {
let t = torrent.get().unwrap();
let t_hash = hash.clone();
let t_hash_class = t_hash.clone();
let on_context_menu = on_context_menu.clone();
let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" };
let status_str = format!("{:?}", t.status);
let status_class = match t.status {
shared::TorrentStatus::Seeding => "text-success",
shared::TorrentStatus::Downloading => "text-primary",
shared::TorrentStatus::Paused => "text-warning",
shared::TorrentStatus::Error => "text-error",
_ => "text-base-content/50"
};
view! {
<tr
class=move || {
let base = "hover border-b border-base-200 select-none";
if selected_hash.get() == Some(t_hash_class.clone()) {
format!("{} bg-primary/10", base)
} else {
base.to_string()
}
}
on:contextmenu={
let t_hash = t_hash.clone();
let on_context_menu = on_context_menu.clone();
move |e: web_sys::MouseEvent| on_context_menu(e, t_hash.clone())
}
on:click={
let t_hash = t_hash.clone();
move |_| set_selected_hash.set(Some(t_hash.clone()))
}
>
<td class="font-medium truncate max-w-xs" title={t.name.clone()}>
{t.name}
</td>
<td class="opacity-80 font-mono text-[11px]">{format_bytes(t.size)}</td>
<td>
<div class="flex items-center gap-2">
<progress class={format!("progress w-24 {}", progress_class)} value={t.percent_complete} max="100"></progress>
<span class="text-[10px] opacity-70">{format!("{:.1}%", t.percent_complete)}</span>
</div>
</td>
<td class={format!("text-[11px] font-medium {}", status_class)}>{status_str}</td>
<td class="text-right font-mono text-[11px] opacity-80 text-success">{format_speed(t.down_rate)}</td>
<td class="text-right font-mono text-[11px] opacity-80 text-primary">{format_speed(t.up_rate)}</td>
<td class="text-right font-mono text-[11px] opacity-80">{format_duration(t.eta)}</td>
<td class="text-right font-mono text-[11px] opacity-80 whitespace-nowrap">{format_date(t.added_date)}</td>
</tr>
}
}
}
</Show>
}
}
#[component]
fn TorrentCard(
hash: String,
selected_hash: ReadSignal<Option<String>>,
set_selected_hash: WriteSignal<Option<String>>,
set_menu_position: WriteSignal<(i32, i32)>,
set_menu_visible: WriteSignal<bool>,
on_context_menu: impl Fn(web_sys::MouseEvent, String) + 'static + Clone,
) -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let h = hash.clone();
let torrent = create_memo(move |_| {
store.torrents.with(|map| map.get(&h).cloned())
});
view! {
<Show when=move || torrent.get().is_some() fallback=|| ()>
{
let hash = hash.clone();
let on_context_menu = on_context_menu.clone();
move || {
let t = torrent.get().unwrap();
let t_hash = hash.clone();
let t_hash_class = t_hash.clone();
let on_context_menu = on_context_menu.clone();
let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" }; let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" };
let status_str = format!("{:?}", t.status); let status_str = format!("{:?}", t.status);
let status_badge_class = match t.status { let status_badge_class = match t.status {
@@ -410,79 +504,70 @@ pub fn TorrentTable() -> impl IntoView {
shared::TorrentStatus::Error => "badge-error badge-soft", shared::TorrentStatus::Error => "badge-error badge-soft",
_ => "badge-ghost" _ => "badge-ghost"
}; };
let _t_hash = t.hash.clone();
let t_hash_click = t.hash.clone();
let (timer_id, set_timer_id) = create_signal(Option::<i32>::None); let t_hash_long = t_hash.clone();
let t_hash_long = t.hash.clone(); let leptos_use::UseTimeoutFnReturn { start, stop, .. } = use_timeout_fn(
move |pos: (i32, i32)| {
let clear_timer = move || { set_menu_position.set(pos);
if let Some(id) = timer_id.get_untracked() { set_selected_hash.set(Some(t_hash_long.clone()));
window().clear_timeout_with_handle(id);
set_timer_id.set(None);
}
};
let handle_touchstart = {
let t_hash = t_hash_long.clone();
move |e: web_sys::TouchEvent| {
clear_timer();
if let Some(touch) = e.touches().get(0) {
let x = touch.client_x();
let y = touch.client_y();
let hash = t_hash.clone();
let closure = Closure::wrap(Box::new(move || {
set_menu_position.set((x, y));
set_selected_hash.set(Some(hash.clone()));
set_menu_visible.set(true); set_menu_visible.set(true);
// Haptic feedback (iOS Safari doesn't support vibrate) // Haptic feedback
let navigator = window().navigator(); let navigator = window().navigator();
if js_sys::Reflect::has(&navigator, &wasm_bindgen::JsValue::from_str("vibrate")).unwrap_or(false) { if let Ok(vibrate) = js_sys::Reflect::get(&navigator, &"vibrate".into()) {
if vibrate.is_function() {
let _ = navigator.vibrate_with_duration(50); let _ = navigator.vibrate_with_duration(50);
} }
}) as Box<dyn Fn()>); }
},
600.0,
);
let id = window() let handle_touchstart = {
.set_timeout_with_callback_and_timeout_and_arguments_0( let start = start.clone();
closure.as_ref().unchecked_ref(), move |e: web_sys::TouchEvent| {
600 if let Some(touch) = e.touches().get(0) {
) start((touch.client_x(), touch.client_y()));
.unwrap_or(0);
closure.forget();
set_timer_id.set(Some(id));
} }
} }
}; };
let handle_touchmove = move |_| { let handle_touchmove = {
clear_timer(); let stop = stop.clone();
move |_| stop()
}; };
let handle_touchend = move |_| { let handle_touchend = {
clear_timer(); let stop = stop.clone();
move |_| stop()
}; };
let handle_touchcancel = move |_| stop();
view! { view! {
<div <div
class=move || { class=move || {
"card card-compact bg-base-100 shadow-sm border border-base-200 transition-transform active:scale-[0.99] select-none" let base = "card card-compact bg-base-100 shadow-sm border border-base-200 transition-transform active:scale-[0.99] select-none cursor-pointer";
if selected_hash.get() == Some(t_hash_class.clone()) {
format!("{} ring-2 ring-primary ring-inset", base)
} else {
base.to_string()
}
} }
style="user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;" style="user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;"
on:contextmenu={ on:contextmenu={
let t_hash = t.hash.clone(); let t_hash = t_hash.clone();
move |e: web_sys::MouseEvent| handle_context_menu(e, t_hash.clone()) let on_context_menu = on_context_menu.clone();
move |e: web_sys::MouseEvent| on_context_menu(e, t_hash.clone())
} }
on:click={ on:click={
let t_hash = t_hash_click.clone(); let t_hash = t_hash.clone();
move |_| set_selected_hash.set(Some(t_hash.clone())) move |_| set_selected_hash.set(Some(t_hash.clone()))
} }
on:touchstart=handle_touchstart on:touchstart=handle_touchstart
on:touchmove=handle_touchmove on:touchmove=handle_touchmove
on:touchend=handle_touchend on:touchend=handle_touchend
on:touchcancel=handle_touchend on:touchcancel=handle_touchcancel
> >
<div class="card-body gap-3"> <div class="card-body gap-3">
<div class="flex justify-between items-start gap-2"> <div class="flex justify-between items-start gap-2">
@@ -500,7 +585,7 @@ pub fn TorrentTable() -> impl IntoView {
<progress class={format!("progress w-full h-1.5 {}", progress_class)} value={t.percent_complete} max="100"></progress> <progress class={format!("progress w-full h-1.5 {}", progress_class)} value={t.percent_complete} max="100"></progress>
</div> </div>
<div class="grid grid-cols-3 gap-2 text-[10px] font-mono opacity-80 pt-1 border-t border-base-200/50"> <div class="grid grid-cols-4 gap-2 text-[10px] font-mono opacity-80 pt-1 border-t border-base-200/50">
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-[9px] opacity-60 uppercase">"Down"</span> <span class="text-[9px] opacity-60 uppercase">"Down"</span>
<span class="text-success">{format_speed(t.down_rate)}</span> <span class="text-success">{format_speed(t.down_rate)}</span>
@@ -509,27 +594,20 @@ pub fn TorrentTable() -> impl IntoView {
<span class="text-[9px] opacity-60 uppercase">"Up"</span> <span class="text-[9px] opacity-60 uppercase">"Up"</span>
<span class="text-primary">{format_speed(t.up_rate)}</span> <span class="text-primary">{format_speed(t.up_rate)}</span>
</div> </div>
<div class="flex flex-col text-right"> <div class="flex flex-col text-center border-r border-base-200/50">
<span class="text-[9px] opacity-60 uppercase">"ETA"</span> <span class="text-[9px] opacity-60 uppercase">"ETA"</span>
<span>{format_duration(t.eta)}</span> <span>{format_duration(t.eta)}</span>
</div> </div>
<div class="flex flex-col text-right">
<span class="text-[9px] opacity-60 uppercase">"Date"</span>
<span>{format_date(t.added_date)}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
} }
}).collect::<Vec<_>>()} }
</div> }
</div>
<Show when=move || menu_visible.get() fallback=|| ()>
<crate::components::context_menu::ContextMenu
visible=true
position=menu_position.get()
torrent_hash=selected_hash.get().unwrap_or_default()
on_close=Callback::from(move |_| set_menu_visible.set(false))
on_action=Callback::from(on_action)
/>
</Show> </Show>
</div>
} }
} }

View File

@@ -1,8 +1,8 @@
mod app; mod app;
// mod models; // Removed
mod components; mod components;
pub mod utils; pub mod utils;
pub mod store; pub mod store;
pub mod api;
use leptos::*; use leptos::*;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
@@ -11,11 +11,15 @@ use app::App;
#[wasm_bindgen(start)] #[wasm_bindgen(start)]
pub fn main() { pub fn main() {
console_error_panic_hook::set_once(); 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 window = web_sys::window()
let document = window.document().unwrap(); .expect("Failed to access window - browser may not be fully loaded");
let body = document.body().unwrap(); 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 // Add app-loaded class to body to hide spinner via CSS
let _ = body.class_list().add_1("app-loaded"); let _ = body.class_list().add_1("app-loaded");

View File

@@ -113,9 +113,11 @@ impl FilterStatus {
} }
} }
use std::collections::HashMap;
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct TorrentStore { pub struct TorrentStore {
pub torrents: RwSignal<Vec<Torrent>>, pub torrents: RwSignal<HashMap<String, Torrent>>,
pub filter: RwSignal<FilterStatus>, pub filter: RwSignal<FilterStatus>,
pub search_query: RwSignal<String>, pub search_query: RwSignal<String>,
pub global_stats: RwSignal<GlobalStats>, pub global_stats: RwSignal<GlobalStats>,
@@ -124,13 +126,16 @@ pub struct TorrentStore {
} }
pub fn provide_torrent_store() { pub fn provide_torrent_store() {
let torrents = create_rw_signal(Vec::<Torrent>::new()); let torrents = create_rw_signal(HashMap::new());
let filter = create_rw_signal(FilterStatus::All); let filter = create_rw_signal(FilterStatus::All);
let search_query = create_rw_signal(String::new()); let search_query = create_rw_signal(String::new());
let global_stats = create_rw_signal(GlobalStats::default()); let global_stats = create_rw_signal(GlobalStats::default());
let notifications = create_rw_signal(Vec::<NotificationItem>::new()); let notifications = create_rw_signal(Vec::<NotificationItem>::new());
let user = create_rw_signal(Option::<String>::None); let user = create_rw_signal(Option::<String>::None);
// Browser notification hook
let show_browser_notification = crate::utils::notification::use_app_notification();
let store = TorrentStore { let store = TorrentStore {
torrents, torrents,
filter, filter,
@@ -143,6 +148,13 @@ pub fn provide_torrent_store() {
// Initialize SSE connection with auto-reconnect // Initialize SSE connection with auto-reconnect
create_effect(move |_| { create_effect(move |_| {
// Sadece kullanıcı giriş yapmışsa bağlantıyı başlat
if user.get().is_none() {
logging::log!("SSE: User not authenticated, skipping connection.");
return;
}
let show_browser_notification = show_browser_notification.clone();
spawn_local(async move { spawn_local(async move {
let mut backoff_ms: u32 = 1000; // Start with 1 second let mut backoff_ms: u32 = 1000; // Start with 1 second
let max_backoff_ms: u32 = 30000; // Max 30 seconds let max_backoff_ms: u32 = 30000; // Max 30 seconds
@@ -183,12 +195,30 @@ pub fn provide_torrent_store() {
if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) { if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) {
match event { match event {
AppEvent::FullList { torrents: list, .. } => { AppEvent::FullList { torrents: list, .. } => {
torrents.set(list); torrents.update(|map| {
// 1. Create a set of new hashes for quick lookup
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
// 2. Remove torrents that are no longer in the list
map.retain(|hash, _| new_hashes.contains(hash));
// 3. Update or Insert torrents from the new list
for new_torrent in list {
if let Some(existing) = map.get_mut(&new_torrent.hash) {
// Only update if changed (Torrent derives PartialEq)
if existing != &new_torrent {
*existing = new_torrent;
}
} else {
// New torrent, insert it
map.insert(new_torrent.hash.clone(), new_torrent);
}
}
});
} }
AppEvent::Update(update) => { AppEvent::Update(update) => {
torrents.update(|list| { torrents.update(|map| {
if let Some(t) = list.iter_mut().find(|t| t.hash == update.hash) if let Some(t) = map.get_mut(&update.hash) {
{
if let Some(name) = update.name { if let Some(name) = update.name {
t.name = name; t.name = name;
} }
@@ -241,7 +271,7 @@ pub fn provide_torrent_store() {
shared::NotificationLevel::Info => " VibeTorrent", shared::NotificationLevel::Info => " VibeTorrent",
}; };
crate::utils::notification::show_notification_if_enabled( show_browser_notification(
title, title,
&n.message &n.message
); );
@@ -304,118 +334,52 @@ use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
struct PushSubscriptionData { pub struct PushSubscriptionData {
endpoint: String, pub endpoint: String,
keys: PushKeys, pub keys: PushKeys,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
struct PushKeys { pub struct PushKeys {
p256dh: String, pub p256dh: String,
auth: String, pub auth: String,
} }
/// Subscribe user to push notifications /// Subscribe user to push notifications
/// Requests notification permission if needed, then subscribes to push /// Requests notification permission if needed, then subscribes to push
pub async fn subscribe_to_push_notifications() { pub async fn subscribe_to_push_notifications() {
use gloo_net::http::Request; use crate::api;
// First, request notification permission if not already granted // First, request notification permission if not already granted
let window = web_sys::window().expect("window should exist"); 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 permission = crate::utils::notification::get_notification_permission();
let current_permission = js_sys::Reflect::get(&notification_class, &"permission".into()) if permission == "default" {
.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..."); log::info!("Requesting notification permission...");
if let Ok(request_fn) = js_sys::Reflect::get(&notification_class, &"requestPermission".into()) { if !crate::utils::notification::request_notification_permission().await {
if request_fn.is_function() { log::warn!("Notification permission denied by user");
let request_fn_typed = js_sys::Function::from(request_fn);
match request_fn_typed.call0(&notification_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; return;
}; }
} else if permission == "denied" {
if !permission_granted { log::warn!("Notification permission was denied");
log::warn!("Notification permission not granted, cannot subscribe to push");
return; return;
} }
log::info!("Notification permission granted! Proceeding with push subscription..."); log::info!("Notification permission granted! Proceeding with push subscription...");
// Get VAPID public key from backend // Get VAPID public key from backend
let public_key_response = match Request::get("/api/push/public-key").send().await { let public_key = match api::push::get_public_key().await {
Ok(resp) => resp, Ok(key) => key,
Err(e) => { Err(e) => {
log::error!("Failed to get VAPID public key: {:?}", e); log::error!("Failed to get VAPID public key: {:?}", e);
return; 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()); log::info!("VAPID public key from backend: {} (len: {})", public_key, public_key.len());
// Convert VAPID public key to Uint8Array // Convert VAPID public key to Uint8Array
let public_key_array = match url_base64_to_uint8array(public_key) { let public_key_array = match url_base64_to_uint8array(&public_key) {
Ok(arr) => { Ok(arr) => {
log::info!("VAPID key converted to Uint8Array (length: {})", arr.length()); log::info!("VAPID key converted to Uint8Array (length: {})", arr.length());
arr arr
@@ -427,7 +391,6 @@ pub async fn subscribe_to_push_notifications() {
}; };
// Get service worker registration // Get service worker registration
let window = web_sys::window().expect("window should exist");
let navigator = window.navigator(); let navigator = window.navigator();
let service_worker = navigator.service_worker(); let service_worker = navigator.service_worker();
@@ -488,12 +451,14 @@ pub async fn subscribe_to_push_notifications() {
.dyn_into::<web_sys::PushSubscription>() .dyn_into::<web_sys::PushSubscription>()
.expect("should be PushSubscription"); .expect("should be PushSubscription");
// Get subscription JSON using toJSON() method // PushSubscription objects can be serialized directly via JSON.stringify which calls their toJSON method internally.
let json_result = match js_sys::Reflect::get(&push_subscription, &"toJSON".into()) { // Or we can use Reflect to call toJSON if we want the object directly.
// Let's use the robust way: call toJSON via Reflect but handle it gracefully.
let json_val = match js_sys::Reflect::get(&push_subscription, &"toJSON".into()) {
Ok(func) if func.is_function() => { Ok(func) if func.is_function() => {
let json_func = js_sys::Function::from(func); let json_func = js_sys::Function::from(func);
match json_func.call0(&push_subscription) { match json_func.call0(&push_subscription) {
Ok(result) => result, Ok(res) => res,
Err(e) => { Err(e) => {
log::error!("Failed to call toJSON: {:?}", e); log::error!("Failed to call toJSON: {:?}", e);
return; return;
@@ -501,25 +466,30 @@ pub async fn subscribe_to_push_notifications() {
} }
} }
_ => { _ => {
log::error!("toJSON method not found on PushSubscription"); // Fallback: try to stringify the object directly
return; // log::warn!("toJSON not found, trying JSON.stringify");
} let json_str = match js_sys::JSON::stringify(&push_subscription) {
}; Ok(s) => s,
let json_value = match js_sys::JSON::stringify(&json_result) {
Ok(val) => val,
Err(e) => { Err(e) => {
log::error!("Failed to stringify subscription: {:?}", e); log::error!("Failed to stringify subscription: {:?}", e);
return; return;
} }
}; };
// Parse back to object to match our expected flow (slightly inefficient but safe)
match js_sys::JSON::parse(&String::from(json_str)) {
Ok(v) => v,
Err(e) => {
log::error!("Failed to parse stringified subscription: {:?}", e);
return;
}
}
}
};
let subscription_json_str = json_value.as_string().expect("should be string"); // Convert JsValue (JSON object) to PushSubscriptionJSON struct via serde
// Note: web_sys::PushSubscriptionJSON is not a struct we can directly use with serde_json usually,
log::info!("Push subscription: {}", subscription_json_str); // but we can use serde-wasm-bindgen to convert JsValue -> Rust Struct
let subscription_data: PushSubscriptionData = match serde_wasm_bindgen::from_value(json_val) {
// Parse and send to backend
let subscription_data: serde_json::Value = match serde_json::from_str(&subscription_json_str) {
Ok(data) => data, Ok(data) => data,
Err(e) => { Err(e) => {
log::error!("Failed to parse subscription JSON: {:?}", e); log::error!("Failed to parse subscription JSON: {:?}", e);
@@ -527,84 +497,22 @@ pub async fn subscribe_to_push_notifications() {
} }
}; };
// Extract endpoint and keys // Send to backend (subscription_data is already the struct we need)
let endpoint = subscription_data if let Err(e) = api::push::subscribe(&subscription_data).await {
.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); 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());
} }
} }
/// Helper to convert URL-safe base64 string to Uint8Array /// Helper to convert URL-safe base64 string to Uint8Array
/// Uses JavaScript to properly decode binary data (avoids UTF-8 encoding issues) /// Uses pure Rust base64 crate for better safety and performance
fn url_base64_to_uint8array(base64_string: &str) -> Result<js_sys::Uint8Array, JsValue> { fn url_base64_to_uint8array(base64_string: &str) -> Result<js_sys::Uint8Array, JsValue> {
// Add padding use base64::{engine::general_purpose, Engine as _};
let padding = (4 - (base64_string.len() % 4)) % 4;
let mut padded = base64_string.to_string();
padded.push_str(&"=".repeat(padding));
// Replace URL-safe characters // VAPID keys are URL-safe base64. Try both NO_PAD and padded for robustness.
let standard_base64 = padded.replace('-', "+").replace('_', "/"); let bytes = general_purpose::URL_SAFE_NO_PAD
.decode(base64_string)
.or_else(|_| general_purpose::URL_SAFE.decode(base64_string))
.map_err(|e| JsValue::from_str(&format!("Base64 decode error: {}", e)))?;
// Decode using JavaScript to avoid UTF-8 encoding issues Ok(js_sys::Uint8Array::from(&bytes[..]))
// 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)
} }

View File

@@ -1,37 +1,19 @@
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use web_sys::{Notification, NotificationOptions}; use web_sys::{Notification, NotificationOptions};
use leptos::*;
use leptos_use::{use_web_notification, UseWebNotificationReturn, NotificationPermission};
/// Request browser notification permission from user /// Request browser notification permission from user
pub async fn request_notification_permission() -> bool { pub async fn request_notification_permission() -> bool {
let window = web_sys::window().expect("no global window"); if let Ok(promise) = Notification::request_permission() {
if let Ok(result) = wasm_bindgen_futures::JsFuture::from(promise).await {
// Check if Notification API is available return result.as_string().unwrap_or_default() == "granted";
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(&notification, &JsValue::from_str("requestPermission"))
.expect("requestPermission should exist");
if let Ok(function) = promise.dyn_into::<js_sys::Function>() {
if let Ok(promise) = function.call0(&notification) {
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";
} }
} }
}
}
}
false false
} }
/// Check if browser notifications are supported and permitted /// Check if browser notifications are supported
pub fn is_notification_supported() -> bool { pub fn is_notification_supported() -> bool {
let window = web_sys::window().expect("no global window"); let window = web_sys::window().expect("no global window");
js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false) js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false)
@@ -39,67 +21,61 @@ pub fn is_notification_supported() -> bool {
/// Get current notification permission status /// Get current notification permission status
pub fn get_notification_permission() -> String { pub fn get_notification_permission() -> String {
if !is_notification_supported() { match Notification::permission() {
return "unsupported".to_string(); 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(&notification, &JsValue::from_str("permission"))
.unwrap_or(JsValue::from_str("default"));
permission.as_string().unwrap_or("default".to_string())
} }
/// Show a browser notification /// Hook for using browser notifications within Leptos components or effects.
/// Returns true if notification was shown successfully /// This uses leptos-use for reactive permission tracking.
pub fn show_browser_notification(title: &str, body: &str, icon: Option<&str>) -> bool { pub fn use_app_notification() -> impl Fn(&str, &str) + Clone {
// Check permission first let UseWebNotificationReturn { permission, .. } = use_web_notification();
let permission = get_notification_permission();
if permission != "granted" { move |title: &str, body: &str| {
log::warn!("Notification permission not granted: {}", permission); // 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());
// Use the reactive permission signal from leptos-use
if enabled == "true" && permission.get() == NotificationPermission::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; return false;
} }
// Create notification options
let opts = NotificationOptions::new(); let opts = NotificationOptions::new();
opts.set_body(body); opts.set_body(body);
opts.set_icon(icon.unwrap_or("/icon-192.png")); opts.set_icon("/icon-192.png");
opts.set_badge("/icon-192.png");
opts.set_tag("vibetorrent"); 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) { match Notification::new_with_options(title, &opts) {
Ok(_notification) => { Ok(_) => true,
true Err(_) => false,
}
Err(e) => {
log::error!("Failed to create notification: {:?}", e);
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 { 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 window = web_sys::window().expect("no global window");
let storage = window.local_storage().ok().flatten(); let storage = window.local_storage().ok().flatten();
if let Some(storage) = storage {
let enabled = storage let enabled = storage
.get_item("vibetorrent_browser_notifications") .and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten())
.ok() .unwrap_or_else(|| "true".to_string());
.flatten()
.unwrap_or("true".to_string());
if enabled == "true" { if enabled == "true" {
return show_browser_notification(title, body, None); return show_browser_notification(title, body);
}
} }
false false

View File

@@ -130,3 +130,9 @@ pub struct SetLabelRequest {
pub hash: String, pub hash: String,
pub label: String, pub label: String,
} }
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct AddTorrentRequest {
#[schema(example = "magnet:?xt=urn:btih:...")]
pub uri: String,
}