Compare commits

...

14 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
19 changed files with 1000 additions and 674 deletions

111
Cargo.lock generated
View File

@@ -566,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"
@@ -790,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"
@@ -823,6 +867,18 @@ 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"
@@ -1144,7 +1200,9 @@ dependencies = [
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",
@@ -1152,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",
@@ -1819,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"
@@ -1986,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"
@@ -3934,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",
] ]
@@ -4298,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"
@@ -5055,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

@@ -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();

View File

@@ -45,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(
@@ -125,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,
@@ -172,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,
@@ -336,6 +337,8 @@ async fn main() {
#[cfg(not(feature = "push-notifications"))] #[cfg(not(feature = "push-notifications"))]
let push_store = (); 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(),
@@ -343,6 +346,7 @@ async fn main() {
db: db.clone(), db: db.clone(),
#[cfg(feature = "push-notifications")] #[cfg(feature = "push-notifications")]
push_store, push_store,
notify_poll: notify_poll.clone(),
}; };
// Spawn background task to poll rTorrent // Spawn background task to poll rTorrent
@@ -351,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);
@@ -359,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;
@@ -429,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);
@@ -449,20 +470,15 @@ 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;
} }
}); });

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 mut stream = UnixStream::connect(socket_path).await?; let perform_request = async {
let data = request.encode(); let mut stream = UnixStream::connect(socket_path).await?;
stream.write_all(&data).await?; let data = request.encode();
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

@@ -30,3 +30,4 @@ base64 = "0.22.1"
serde-wasm-bindgen = "0.6.5" serde-wasm-bindgen = "0.6.5"
leptos-use = "0.13" leptos-use = "0.13"
codee = "0.2" 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,7 +81,7 @@
</script> </script>
</head> </head>
<body> <body style="cursor: pointer;">
<div <div
id="app-loading" id="app-loading"
style=" style="
@@ -142,6 +142,19 @@
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> <script>
@@ -174,6 +187,17 @@
<!-- 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,100 +3,80 @@ 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) => { Ok(status) => {
if resp.ok() { if !status.completed {
match resp.json::<SetupStatus>().await { logging::log!("Setup not completed, redirecting to /setup");
Ok(status) => { let navigate = use_navigate();
if !status.completed { navigate("/setup", Default::default());
logging::log!("Setup not completed, redirecting to /setup"); set_is_loading.set(false);
let navigate = use_navigate(); return;
navigate("/setup", Default::default());
set_is_loading.set(false);
return;
}
}
Err(e) => logging::error!("Failed to parse setup status: {}", e),
}
} }
} }
Err(e) => logging::error!("Network error checking setup status: {}", e), Err(e) => logging::error!("Failed to get 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));
}
}
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();
if pathname == "/login" || pathname == "/setup" {
logging::log!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
} else {
logging::log!("Not authenticated, redirecting to /login");
let navigate = use_navigate();
let pathname = window().location().pathname().unwrap_or_default();
if pathname != "/login" && pathname != "/setup" {
navigate("/login", Default::default());
}
}
}
Err(e) => logging::error!("Network error checking auth status: {}", e),
} }
}
set_is_authenticated.set(true);
let pathname = window().location().pathname().unwrap_or_default();
if pathname == "/login" || pathname == "/setup" {
logging::log!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
}
Ok(false) => {
logging::log!("Not authenticated");
let pathname = window().location().pathname().unwrap_or_default();
if pathname != "/login" && pathname != "/setup" {
let navigate = use_navigate();
navigate("/login", Default::default());
}
}
Err(e) => {
logging::error!("Auth check failed: {:?}", e);
let navigate = use_navigate();
navigate("/login", Default::default());
}
}
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,35 +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(), logging::log!("Login successful, redirecting...");
remember_me: remember_me.get(), let _ = window().location().set_href("/");
};
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...");
// Force a full reload to re-run auth checks in App.rs
let _ = window().location().set_href("/");
} else if resp.status() == 429 {
set_error.set(Some("Çok fazla başarısız deneme yaptınız. Lütfen bir süre bekleyip tekrar deneyin.".to_string()));
} 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 _ = window().location().set_href("/");
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("/");
} else {
let text = resp.text().await.unwrap_or_default();
set_error.set(Some(format!("Hata: {}", text)));
}
} }
Err(_) => { Err(e) => {
set_error.set(Some("Bağlantı hatası".to_string())); logging::error!("Setup failed: {:?}", e);
set_error.set(Some("Kurulum başarısız oldu".to_string()));
} }
} }
set_loading.set(false); set_loading.set(false);

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() .filter(|t| t.status == shared::TorrentStatus::Downloading)
.iter() .count()
.filter(|t| t.status == shared::TorrentStatus::Downloading) })
.count()
}; };
let seeding_count = move || { let seeding_count = move || {
store store.torrents.with(|map| {
.torrents map.values()
.get() .filter(|t| t.status == shared::TorrentStatus::Seeding)
.iter() .count()
.filter(|t| t.status == shared::TorrentStatus::Seeding) })
.count()
}; };
let completed_count = move || { let completed_count = move || {
store store.torrents.with(|map| {
.torrents map.values()
.get() .filter(|t| {
.iter() t.status == shared::TorrentStatus::Seeding
.filter(|t| { || (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0)
t.status == shared::TorrentStatus::Seeding })
|| (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() .filter(|t| t.status == shared::TorrentStatus::Paused)
.iter() .count()
.filter(|t| t.status == shared::TorrentStatus::Paused) })
.count()
}; };
let inactive_count = move || { let inactive_count = move || {
store store.torrents.with(|map| {
.torrents map.values()
.get() .filter(|t| {
.iter() t.status == shared::TorrentStatus::Paused
.filter(|t| { || t.status == shared::TorrentStatus::Error
t.status == shared::TorrentStatus::Paused })
|| t.status == shared::TorrentStatus::Error .count()
}) })
.count()
}; };
let close_drawer = move || { let close_drawer = move || {
@@ -76,12 +72,8 @@ 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 { let _ = window().location().set_href("/login");
if resp.ok() {
// Force full reload to clear state
let _ = window().location().set_href("/login");
}
} }
}); });
}; };

View File

@@ -1,8 +1,8 @@
use leptos::*; use leptos::*;
use leptos_use::on_click_outside;
use leptos_use::storage::use_local_storage; use leptos_use::storage::use_local_storage;
use codee::string::FromToStringCodec; 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"];
@@ -61,43 +61,28 @@ 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);
let req = if limit_type == "down" {
GlobalLimitRequest {
max_download_rate: Some(val),
max_upload_rate: None,
}
} else {
GlobalLimitRequest {
max_download_rate: None,
max_upload_rate: Some(val),
}
};
spawn_local(async move { spawn_local(async move {
let req_body = if limit_type == "down" { if let Err(e) = api::settings::set_global_limits(&req).await {
GlobalLimitRequest { logging::error!("Failed to set limit: {:?}", e);
max_download_rate: Some(val),
max_upload_rate: None,
}
} else { } else {
GlobalLimitRequest { logging::log!("Limit set successfully");
max_download_rate: None,
max_upload_rate: Some(val),
}
};
let client =
gloo_net::http::Request::post("/api/settings/global-limits").json(&req_body);
match client {
Ok(req) => match req.send().await {
Ok(resp) => {
if !resp.ok() {
logging::error!(
"Failed to set limit: {} {}",
resp.status(),
resp.status_text()
);
} else {
logging::log!("Limit set successfully");
}
}
Err(e) => logging::error!("Network error setting limit: {}", e),
},
Err(e) => logging::error!("Failed to create request: {}", e),
} }
}); });
}; };
// Refs for click outside detection // Refs for click outside detection (Handled globally via JS in index.html for better iOS support)
let down_details_ref = create_node_ref::<html::Details>(); let down_details_ref = create_node_ref::<html::Details>();
let up_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>(); let theme_details_ref = create_node_ref::<html::Details>();
@@ -109,16 +94,12 @@ pub fn StatusBar() -> impl IntoView {
} }
}; };
let _ = on_click_outside(down_details_ref, move |_| close_details(down_details_ref));
let _ = on_click_outside(up_details_ref, move |_| close_details(up_details_ref));
let _ = on_click_outside(theme_details_ref, move |_| close_details(theme_details_ref));
view! { view! {
<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]"> <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">
// --- DOWNLOAD SPEED DROPDOWN --- // --- DOWNLOAD SPEED DROPDOWN ---
<details class="dropdown dropdown-top" node_ref=down_details_ref> <details class="dropdown dropdown-top" node_ref=down_details_ref>
<summary class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none list-none marker:hidden"> <summary class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <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>
@@ -160,7 +141,7 @@ pub fn StatusBar() -> impl IntoView {
// --- UPLOAD SPEED DROPDOWN --- // --- UPLOAD SPEED DROPDOWN ---
<details class="dropdown dropdown-top" node_ref=up_details_ref> <details class="dropdown dropdown-top" node_ref=up_details_ref>
<summary class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none list-none marker:hidden"> <summary class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <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>
@@ -202,7 +183,7 @@ pub fn StatusBar() -> impl IntoView {
<div class="ml-auto flex items-center gap-4"> <div class="ml-auto flex items-center gap-4">
<details class="dropdown dropdown-top dropdown-end" node_ref=theme_details_ref> <details class="dropdown dropdown-top dropdown-end" node_ref=theme_details_ref>
<summary class="btn btn-ghost btn-xs btn-square"> <summary class="btn btn-ghost btn-xs btn-square cursor-pointer outline-none list-none [&::-webkit-details-marker]:hidden">
<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>

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,46 +35,22 @@ 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(_) => {
}); logging::log!("Torrent added successfully");
show_toast_with_signal(notifications, NotificationLevel::Success, "Torrent eklendi");
match gloo_net::http::Request::post("/api/torrents/add") set_loading.set(false);
.json(&req_body) if let Some(dialog) = dialog_ref.get() {
{ dialog.close();
Ok(req) => {
match req.send().await {
Ok(resp) => {
if resp.ok() {
logging::log!("Torrent added successfully");
show_toast_with_signal(notifications, NotificationLevel::Success, "Torrent eklendi");
set_loading.set(false);
if let Some(dialog) = dialog_ref.get() {
dialog.close();
}
on_close.call(());
} else {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
logging::error!("Failed to add torrent: {} - {}", status, text);
show_toast_with_signal(notifications, NotificationLevel::Error, "Torrent eklenemedi");
set_error_msg.set(Some(format!("Error {}: {}", status, text)));
set_loading.set(false);
}
}
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);
}
} }
on_close.call(());
} }
Err(e) => { Err(e) => {
logging::error!("Serialization error: {}", e); logging::error!("Failed to add torrent: {:?}", e);
show_toast_with_signal(notifications, NotificationLevel::Error, "İstek hatası"); show_toast_with_signal(notifications, NotificationLevel::Error, "Torrent eklenemedi");
set_error_msg.set(Some(format!("Request Error: {}", e))); set_error_msg.set(Some(format!("Error: {:?}", e)));
set_loading.set(false); set_loading.set(false);
} }
} }

View File

@@ -1,6 +1,7 @@
use leptos::*; use leptos::*;
use leptos_use::{on_click_outside, use_timeout_fn}; use leptos_use::{on_click_outside, use_timeout_fn};
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 {
@@ -80,74 +81,77 @@ pub fn TorrentTable() -> impl IntoView {
let sort_col = create_rw_signal(SortColumn::AddedDate); let sort_col = create_rw_signal(SortColumn::AddedDate);
let sort_dir = create_rw_signal(SortDirection::Descending); 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();
let matches_filter = match filter { let matches_filter = match filter {
crate::store::FilterStatus::All => true, crate::store::FilterStatus::All => true,
crate::store::FilterStatus::Downloading => { crate::store::FilterStatus::Downloading => {
t.status == shared::TorrentStatus::Downloading t.status == shared::TorrentStatus::Downloading
}
crate::store::FilterStatus::Seeding => {
t.status == shared::TorrentStatus::Seeding
}
crate::store::FilterStatus::Completed => {
t.status == shared::TorrentStatus::Seeding
|| (t.status == shared::TorrentStatus::Paused
&& t.percent_complete >= 100.0)
}
crate::store::FilterStatus::Paused => {
t.status == shared::TorrentStatus::Paused
}
crate::store::FilterStatus::Inactive => {
t.status == shared::TorrentStatus::Paused
|| t.status == shared::TorrentStatus::Error
}
_ => true,
};
let matches_search = if search.is_empty() {
true
} else {
t.name.to_lowercase().contains(&search)
};
matches_filter && matches_search
})
.collect();
torrents.sort_by(|a, b| {
let col = sort_col.get();
let dir = sort_dir.get();
let cmp = match col {
SortColumn::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
SortColumn::Size => a.size.cmp(&b.size),
SortColumn::Progress => a
.percent_complete
.partial_cmp(&b.percent_complete)
.unwrap_or(std::cmp::Ordering::Equal),
SortColumn::Status => format!("{:?}", a.status).cmp(&format!("{:?}", b.status)),
SortColumn::DownSpeed => a.down_rate.cmp(&b.down_rate),
SortColumn::UpSpeed => a.up_rate.cmp(&b.up_rate),
SortColumn::ETA => {
let a_eta = if a.eta <= 0 { i64::MAX } else { a.eta };
let b_eta = if b.eta <= 0 { i64::MAX } else { b.eta };
a_eta.cmp(&b_eta)
} }
crate::store::FilterStatus::Seeding => { SortColumn::AddedDate => a.added_date.cmp(&b.added_date),
t.status == shared::TorrentStatus::Seeding
}
crate::store::FilterStatus::Completed => {
t.status == shared::TorrentStatus::Seeding
|| (t.status == shared::TorrentStatus::Paused
&& t.percent_complete >= 100.0)
} // Approximate
crate::store::FilterStatus::Paused => t.status == shared::TorrentStatus::Paused,
crate::store::FilterStatus::Inactive => {
t.status == shared::TorrentStatus::Paused
|| t.status == shared::TorrentStatus::Error
}
_ => true,
}; };
if dir == SortDirection::Descending {
let matches_search = if search.is_empty() { cmp.reverse()
true
} else { } else {
t.name.to_lowercase().contains(&search) cmp
};
matches_filter && matches_search
})
.collect::<Vec<_>>();
torrents.sort_by(|a, b| {
let col = sort_col.get();
let dir = sort_dir.get();
let cmp = match col {
SortColumn::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
SortColumn::Size => a.size.cmp(&b.size),
SortColumn::Progress => a
.percent_complete
.partial_cmp(&b.percent_complete)
.unwrap_or(std::cmp::Ordering::Equal),
SortColumn::Status => format!("{:?}", a.status).cmp(&format!("{:?}", b.status)),
SortColumn::DownSpeed => a.down_rate.cmp(&b.down_rate),
SortColumn::UpSpeed => a.up_rate.cmp(&b.up_rate),
SortColumn::ETA => {
let a_eta = if a.eta <= 0 { i64::MAX } else { a.eta };
let b_eta = if b.eta <= 0 { i64::MAX } else { b.eta };
a_eta.cmp(&b_eta)
} }
SortColumn::AddedDate => a.added_date.cmp(&b.added_date), });
};
if dir == SortDirection::Descending {
cmp.reverse()
} else {
cmp
}
});
torrents torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
})
}; };
let handle_sort = move |col: SortColumn| { let handle_sort = move |col: SortColumn| {
@@ -164,10 +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 sort_menu_ref = create_node_ref::<html::Div>(); let _ = on_click_outside(sort_details_ref, move |_| {
let _ = on_click_outside(sort_menu_ref, move |_| set_sort_open.set(false)); 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 {
@@ -198,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(), logging::log!("Action {} executed successfully", action);
}; show_toast_with_signal(notifications, NotificationLevel::Success, success_msg);
}
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);
show_toast_with_signal(notifications, NotificationLevel::Success, success_msg);
}
}
Err(e) => {
logging::error!("Network error executing action: {}", e);
show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: Bağlantı hatası", error_msg));
}
},
Err(e) => { Err(e) => {
logging::error!("Failed to serialize request: {}", e); logging::error!("Action failed: {:?}", e);
show_toast_with_signal(notifications, NotificationLevel::Error, error_msg); show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: {:?}", error_msg, e));
} }
} }
}); });
@@ -270,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>
@@ -284,132 +270,231 @@ pub fn TorrentTable() -> impl IntoView {
</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", view! {
shared::TorrentStatus::Error => "text-error", <TorrentRow
_ => "text-base-content/50" hash=hash.clone()
}; selected_hash=selected_hash
let t_hash = t.hash.clone(); set_selected_hash=set_selected_hash
let t_hash_click = t.hash.clone(); on_context_menu=handle_context_menu.clone()
/>
let is_selected_fn = move || {
selected_hash.get() == Some(t_hash.clone())
};
view! {
<tr
class=move || {
let base = "hover border-b border-base-200 select-none";
if is_selected_fn() {
format!("{} bg-primary/10", base)
} 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>
<td class="text-right font-mono text-[11px] opacity-80 whitespace-nowrap">{format_date(t.added_date)}</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">
<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"> <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">
<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" node_ref=sort_menu_ref> <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" <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" />
on:click=move |_| { </svg>
let cur = sort_open.get_untracked(); <span class="pointer-events-none">"Sort"</span>
set_sort_open.set(!cur); </summary>
<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">
<li class="menu-title px-2 py-1 opacity-50 text-[10px] uppercase font-bold">"Sort By"</li>
{
let columns = vec![
(SortColumn::Name, "Name"),
(SortColumn::Size, "Size"),
(SortColumn::Progress, "Progress"),
(SortColumn::Status, "Status"),
(SortColumn::DownSpeed, "DL Speed"),
(SortColumn::UpSpeed, "Up Speed"),
(SortColumn::ETA, "ETA"),
(SortColumn::AddedDate, "Date"),
];
columns.into_iter().map(|(col, label)| {
let is_active = move || sort_col.get() == col;
let current_dir = move || sort_dir.get();
view! {
<li>
<button
type="button"
class=move || if is_active() { "bg-primary/10 text-primary font-bold flex justify-between" } else { "flex justify-between" }
on:click=move |_| {
handle_sort(col);
if let Some(el) = sort_details_ref.get_untracked() {
el.set_open(false);
}
}
>
{label}
<Show when=is_active fallback=|| ()>
<span class="opacity-70 text-[10px]">
{move || match current_dir() {
SortDirection::Ascending => "",
SortDirection::Descending => "",
}}
</span>
</Show>
</button>
</li>
} }
> }).collect::<Vec<_>>()
<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" /> </ul>
</svg> </details>
"Sort" </div>
</div>
<ul
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" }
>
<li class="menu-title px-2 py-1 opacity-50 text-[10px] uppercase font-bold">"Sort By"</li>
{
let columns = vec![
(SortColumn::Name, "Name"),
(SortColumn::Size, "Size"),
(SortColumn::Progress, "Progress"),
(SortColumn::Status, "Status"),
(SortColumn::DownSpeed, "Down Speed"),
(SortColumn::UpSpeed, "Up Speed"),
(SortColumn::ETA, "ETA"),
(SortColumn::AddedDate, "Date"),
];
columns.into_iter().map(|(col, label)| { <div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3 cursor-pointer">
let is_active = move || sort_col.get() == col; <For
let current_dir = move || sort_dir.get(); 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>
view! { <Show when=move || menu_visible.get() fallback=|| ()>
<li> <crate::components::context_menu::ContextMenu
<button visible=true
type="button" position=menu_position.get()
class=move || if is_active() { "bg-primary/10 text-primary font-bold flex justify-between" } else { "flex justify-between" } torrent_hash=selected_hash.get().unwrap_or_default()
on:click=move |_| { on_close=Callback::from(move |_| set_menu_visible.set(false))
handle_sort(col); on_action=Callback::from(on_action)
set_sort_open.set(false); />
} </Show>
> </div>
{label} }
<Show when=is_active fallback=|| ()> }
<span class="opacity-70 text-[10px]">
{move || match current_dir() { #[component]
SortDirection::Ascending => "", fn TorrentRow(
SortDirection::Descending => "", hash: String,
}} selected_hash: ReadSignal<Option<String>>,
</span> set_selected_hash: WriteSignal<Option<String>>,
</Show> on_context_menu: impl Fn(web_sys::MouseEvent, String) + 'static + Clone,
</button> ) -> impl IntoView {
</li> let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
}
}).collect::<Vec<_>>() let h = hash.clone();
} // Memoized access to the specific torrent data.
</ul> // 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> </div>
</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();
<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| {
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 {
@@ -419,10 +504,8 @@ 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 t_hash_long = t.hash.clone(); let t_hash_long = t_hash.clone();
let leptos_use::UseTimeoutFnReturn { start, stop, .. } = use_timeout_fn( let leptos_use::UseTimeoutFnReturn { start, stop, .. } = use_timeout_fn(
move |pos: (i32, i32)| { move |pos: (i32, i32)| {
set_menu_position.set(pos); set_menu_position.set(pos);
@@ -464,15 +547,21 @@ pub fn TorrentTable() -> impl IntoView {
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
@@ -517,19 +606,8 @@ pub fn TorrentTable() -> impl IntoView {
</div> </div>
</div> </div>
} }
}).collect::<Vec<_>>()} }
</div> }
</div> </Show>
<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>
} }
} }

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

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,
@@ -149,6 +154,7 @@ pub fn provide_torrent_store() {
return; 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
@@ -189,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;
} }
@@ -247,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
); );
@@ -310,97 +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");
// Notification.permission is a static property, but web_sys exposes it via the Notification class instance or we check it manually. let permission = crate::utils::notification::get_notification_permission();
// Actually, Notification::permission() is a static method in web_sys. if permission == "default" {
match web_sys::Notification::permission() { log::info!("Requesting notification permission...");
web_sys::NotificationPermission::Granted => { if !crate::utils::notification::request_notification_permission().await {
log::info!("Notification permission already granted"); log::warn!("Notification permission denied by user");
}
web_sys::NotificationPermission::Denied => {
log::warn!("Notification permission was denied");
return; return;
} }
web_sys::NotificationPermission::Default => { } else if permission == "denied" {
log::info!("Requesting notification permission..."); log::warn!("Notification permission was denied");
let permission_promise = match web_sys::Notification::request_permission() { return;
Ok(p) => p,
Err(e) => {
log::error!("Failed to request notification permission: {:?}", e);
return;
}
};
match wasm_bindgen_futures::JsFuture::from(permission_promise).await {
Ok(val) => {
let permission = val.as_string().unwrap_or_default();
if permission != "granted" {
log::warn!("Notification permission denied by user");
return;
}
log::info!("Notification permission granted by user");
}
Err(e) => {
log::error!("Failed to await notification permission: {:?}", e);
return;
}
}
}
_ => {
log::warn!("Unknown notification permission status");
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
@@ -519,23 +498,8 @@ pub async fn subscribe_to_push_notifications() {
}; };
// Send to backend (subscription_data is already the struct we need) // Send to backend (subscription_data is already the struct we need)
let response = match Request::post("/api/push/subscribe") if let Err(e) = api::push::subscribe(&subscription_data).await {
.json(&subscription_data) log::error!("Failed to send subscription to backend: {:?}", e);
.expect("serialization should succeed")
.send()
.await
{
Ok(resp) => resp,
Err(e) => {
log::error!("Failed to send subscription to backend: {:?}", e);
return;
}
};
if response.ok() {
log::info!("Successfully subscribed to push notifications");
} else {
log::error!("Backend rejected push subscription: {:?}", response.status());
} }
} }

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,68 +21,62 @@ 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();
let enabled = storage
if let Some(storage) = storage { .and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten())
let enabled = storage .unwrap_or_else(|| "true".to_string());
.get_item("vibetorrent_browser_notifications")
.ok()
.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,
}