Compare commits
1 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce10c5dfb2 |
34
Cargo.lock
generated
34
Cargo.lock
generated
@@ -310,6 +310,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"shared",
|
"shared",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"strum",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -544,7 +545,7 @@ version = "4.5.55"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
@@ -1218,7 +1219,6 @@ dependencies = [
|
|||||||
"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",
|
||||||
@@ -1537,6 +1537,12 @@ dependencies = [
|
|||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -3704,7 +3710,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"either",
|
"either",
|
||||||
"heck",
|
"heck 0.5.0",
|
||||||
"hex",
|
"hex",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -3847,6 +3853,28 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum_macros"
|
||||||
|
version = "0.25.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0"
|
||||||
|
dependencies = [
|
||||||
|
"heck 0.4.1",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn 2.0.114",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
|
|||||||
@@ -42,3 +42,4 @@ 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"
|
tower_governor = "0.8.0"
|
||||||
governor = "0.10.4"
|
governor = "0.10.4"
|
||||||
|
strum = { version = "0.25", features = ["derive", "strum_macros"] }
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ 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(
|
||||||
@@ -337,8 +336,6 @@ 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(),
|
||||||
@@ -346,7 +343,6 @@ 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
|
||||||
@@ -355,7 +351,6 @@ 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);
|
||||||
@@ -364,14 +359,6 @@ 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;
|
||||||
|
|
||||||
@@ -442,14 +429,6 @@ 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);
|
||||||
@@ -470,15 +449,20 @@ 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
|
||||||
if let Ok(stats) = stats_result {
|
match stats_result {
|
||||||
let _ = event_bus_tx.send(AppEvent::Stats(stats));
|
Ok(stats) => {
|
||||||
|
let _ = event_bus_tx.send(AppEvent::Stats(stats));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Error fetching global stats: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(backoff_duration).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ 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 {
|
||||||
@@ -80,30 +78,20 @@ 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";
|
||||||
let mut response_vec = response;
|
if let Some(pos) = 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(
|
Ok(Bytes::from(response.split_off(pos + double_newline.len())))
|
||||||
response_vec.split_off(pos + double_newline.len()),
|
|
||||||
))
|
|
||||||
} else {
|
} else {
|
||||||
Ok(Bytes::from(response_vec))
|
Ok(Bytes::from(response))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,95 +7,80 @@ use axum::response::sse::{Event, Sse};
|
|||||||
use futures::stream::{self, Stream};
|
use futures::stream::{self, Stream};
|
||||||
use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus};
|
use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
use strum::{Display, EnumString};
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
// Field definitions to keep query and parser in sync
|
#[derive(Debug, Clone, Copy, EnumString, Display, PartialEq)]
|
||||||
mod fields {
|
enum RtorrentField {
|
||||||
pub const IDX_HASH: usize = 0;
|
#[strum(serialize = "d.hash=")]
|
||||||
pub const CMD_HASH: &str = "d.hash=";
|
Hash,
|
||||||
|
#[strum(serialize = "d.name=")]
|
||||||
pub const IDX_NAME: usize = 1;
|
Name,
|
||||||
pub const CMD_NAME: &str = "d.name=";
|
#[strum(serialize = "d.size_bytes=")]
|
||||||
|
Size,
|
||||||
pub const IDX_SIZE: usize = 2;
|
#[strum(serialize = "d.bytes_done=")]
|
||||||
pub const CMD_SIZE: &str = "d.size_bytes=";
|
Completed,
|
||||||
|
#[strum(serialize = "d.down.rate=")]
|
||||||
pub const IDX_COMPLETED: usize = 3;
|
DownRate,
|
||||||
pub const CMD_COMPLETED: &str = "d.bytes_done=";
|
#[strum(serialize = "d.up.rate=")]
|
||||||
|
UpRate,
|
||||||
pub const IDX_DOWN_RATE: usize = 4;
|
#[strum(serialize = "d.state=")]
|
||||||
pub const CMD_DOWN_RATE: &str = "d.down.rate=";
|
State,
|
||||||
|
#[strum(serialize = "d.complete=")]
|
||||||
pub const IDX_UP_RATE: usize = 5;
|
Complete,
|
||||||
pub const CMD_UP_RATE: &str = "d.up.rate=";
|
#[strum(serialize = "d.message=")]
|
||||||
|
Message,
|
||||||
pub const IDX_STATE: usize = 6;
|
#[strum(serialize = "d.left_bytes=")]
|
||||||
pub const CMD_STATE: &str = "d.state=";
|
LeftBytes,
|
||||||
|
#[strum(serialize = "d.creation_date=")]
|
||||||
pub const IDX_COMPLETE: usize = 7;
|
CreationDate,
|
||||||
pub const CMD_COMPLETE: &str = "d.complete=";
|
#[strum(serialize = "d.hashing=")]
|
||||||
|
Hashing,
|
||||||
pub const IDX_MESSAGE: usize = 8;
|
#[strum(serialize = "d.custom1=")]
|
||||||
pub const CMD_MESSAGE: &str = "d.message=";
|
Label,
|
||||||
|
|
||||||
pub const IDX_LEFT_BYTES: usize = 9;
|
|
||||||
pub const CMD_LEFT_BYTES: &str = "d.left_bytes=";
|
|
||||||
|
|
||||||
pub const IDX_CREATION_DATE: usize = 10;
|
|
||||||
pub const CMD_CREATION_DATE: &str = "d.creation_date=";
|
|
||||||
|
|
||||||
pub const IDX_HASHING: usize = 11;
|
|
||||||
pub const CMD_HASHING: &str = "d.hashing=";
|
|
||||||
|
|
||||||
pub const IDX_LABEL: usize = 12;
|
|
||||||
pub const CMD_LABEL: &str = "d.custom1=";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
use fields::*;
|
const RTORRENT_FIELDS: &[RtorrentField] = &[
|
||||||
|
RtorrentField::Hash,
|
||||||
// Constants for rTorrent fields to ensure query and parser stay in sync
|
RtorrentField::Name,
|
||||||
const RTORRENT_FIELDS: &[&str] = &[
|
RtorrentField::Size,
|
||||||
"", // Ignored by multicall pattern
|
RtorrentField::Completed,
|
||||||
"main", // View
|
RtorrentField::DownRate,
|
||||||
CMD_HASH,
|
RtorrentField::UpRate,
|
||||||
CMD_NAME,
|
RtorrentField::State,
|
||||||
CMD_SIZE,
|
RtorrentField::Complete,
|
||||||
CMD_COMPLETED,
|
RtorrentField::Message,
|
||||||
CMD_DOWN_RATE,
|
RtorrentField::LeftBytes,
|
||||||
CMD_UP_RATE,
|
RtorrentField::CreationDate,
|
||||||
CMD_STATE,
|
RtorrentField::Hashing,
|
||||||
CMD_COMPLETE,
|
RtorrentField::Label,
|
||||||
CMD_MESSAGE,
|
|
||||||
CMD_LEFT_BYTES,
|
|
||||||
CMD_CREATION_DATE,
|
|
||||||
CMD_HASHING,
|
|
||||||
CMD_LABEL,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
fn parse_long(s: Option<&String>) -> i64 {
|
fn get_field_value(row: &Vec<String>, field: RtorrentField) -> String {
|
||||||
s.map(|v| v.parse().unwrap_or(0)).unwrap_or(0)
|
let idx = RTORRENT_FIELDS.iter().position(|&f| f == field).unwrap_or(0);
|
||||||
|
row.get(idx).cloned().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_string(s: Option<&String>) -> String {
|
fn parse_long(s: &str) -> i64 {
|
||||||
s.cloned().unwrap_or_default()
|
s.parse().unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts a raw row of strings from rTorrent XML-RPC into a generic Torrent struct
|
/// Converts a raw row of strings from rTorrent XML-RPC into a generic Torrent struct
|
||||||
fn from_rtorrent_row(row: Vec<String>) -> Torrent {
|
fn from_rtorrent_row(row: &Vec<String>) -> Torrent {
|
||||||
let hash = parse_string(row.get(IDX_HASH));
|
let hash = get_field_value(row, RtorrentField::Hash);
|
||||||
let name = parse_string(row.get(IDX_NAME));
|
let name = get_field_value(row, RtorrentField::Name);
|
||||||
let size = parse_long(row.get(IDX_SIZE));
|
let size = parse_long(&get_field_value(row, RtorrentField::Size));
|
||||||
let completed = parse_long(row.get(IDX_COMPLETED));
|
let completed = parse_long(&get_field_value(row, RtorrentField::Completed));
|
||||||
let down_rate = parse_long(row.get(IDX_DOWN_RATE));
|
let down_rate = parse_long(&get_field_value(row, RtorrentField::DownRate));
|
||||||
let up_rate = parse_long(row.get(IDX_UP_RATE));
|
let up_rate = parse_long(&get_field_value(row, RtorrentField::UpRate));
|
||||||
|
|
||||||
let state = parse_long(row.get(IDX_STATE));
|
let state = parse_long(&get_field_value(row, RtorrentField::State));
|
||||||
let is_complete = parse_long(row.get(IDX_COMPLETE));
|
let is_complete = parse_long(&get_field_value(row, RtorrentField::Complete));
|
||||||
let message = parse_string(row.get(IDX_MESSAGE));
|
let message = get_field_value(row, RtorrentField::Message);
|
||||||
let left_bytes = parse_long(row.get(IDX_LEFT_BYTES));
|
let left_bytes = parse_long(&get_field_value(row, RtorrentField::LeftBytes));
|
||||||
let added_date = parse_long(row.get(IDX_CREATION_DATE));
|
let added_date = parse_long(&get_field_value(row, RtorrentField::CreationDate));
|
||||||
let is_hashing = parse_long(row.get(IDX_HASHING));
|
let is_hashing = parse_long(&get_field_value(row, RtorrentField::Hashing));
|
||||||
let label_raw = parse_string(row.get(IDX_LABEL));
|
let label_raw = get_field_value(row, RtorrentField::Label);
|
||||||
|
|
||||||
let label = if label_raw.is_empty() {
|
let label = if label_raw.is_empty() {
|
||||||
None
|
None
|
||||||
@@ -146,7 +131,10 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_torrents(client: &RtorrentClient) -> Result<Vec<Torrent>, XmlRpcError> {
|
pub async fn fetch_torrents(client: &RtorrentClient) -> Result<Vec<Torrent>, XmlRpcError> {
|
||||||
let params: Vec<RpcParam> = RTORRENT_FIELDS.iter().map(|s| RpcParam::from(*s)).collect();
|
let params: Vec<RpcParam> = RTORRENT_FIELDS
|
||||||
|
.iter()
|
||||||
|
.map(|&f| RpcParam::from(f.to_string()))
|
||||||
|
.collect();
|
||||||
let xml = client.call("d.multicall2", ¶ms).await?;
|
let xml = client.call("d.multicall2", ¶ms).await?;
|
||||||
|
|
||||||
if xml.trim().is_empty() {
|
if xml.trim().is_empty() {
|
||||||
@@ -155,7 +143,7 @@ pub async fn fetch_torrents(client: &RtorrentClient) -> Result<Vec<Torrent>, Xml
|
|||||||
|
|
||||||
let rows = parse_multicall_response(&xml)?;
|
let rows = parse_multicall_response(&xml)?;
|
||||||
|
|
||||||
let torrents = rows.into_iter().map(from_rtorrent_row).collect();
|
let torrents = rows.iter().map(from_rtorrent_row).collect();
|
||||||
|
|
||||||
Ok(torrents)
|
Ok(torrents)
|
||||||
}
|
}
|
||||||
@@ -195,9 +183,6 @@ 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();
|
||||||
|
|||||||
@@ -30,4 +30,3 @@ 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"
|
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,80 +3,100 @@ 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...");
|
||||||
|
|
||||||
let setup_res = api::setup::get_status().await;
|
// 1. Check Setup Status
|
||||||
|
let setup_res = gloo_net::http::Request::get("/api/setup/status").send().await;
|
||||||
|
|
||||||
match setup_res {
|
match setup_res {
|
||||||
Ok(status) => {
|
Ok(resp) => {
|
||||||
if !status.completed {
|
if resp.ok() {
|
||||||
logging::log!("Setup not completed, redirecting to /setup");
|
match resp.json::<SetupStatus>().await {
|
||||||
let navigate = use_navigate();
|
Ok(status) => {
|
||||||
navigate("/setup", Default::default());
|
if !status.completed {
|
||||||
set_is_loading.set(false);
|
logging::log!("Setup not completed, redirecting to /setup");
|
||||||
return;
|
let navigate = use_navigate();
|
||||||
}
|
navigate("/setup", Default::default());
|
||||||
}
|
set_is_loading.set(false);
|
||||||
Err(e) => logging::error!("Failed to get setup status: {:?}", e),
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let auth_res = api::auth::check_auth().await;
|
Err(e) => logging::error!("Failed to parse setup status: {}", e),
|
||||||
|
|
||||||
match auth_res {
|
|
||||||
Ok(true) => {
|
|
||||||
logging::log!("Authenticated!");
|
|
||||||
|
|
||||||
if let Ok(user_info) = api::auth::get_user().await {
|
|
||||||
if let Some(store) = use_context::<crate::store::TorrentStore>() {
|
|
||||||
store.user.set(Some(user_info.username));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
Err(e) => logging::error!("Network error checking setup status: {}", e),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Check Auth Status
|
||||||
|
let auth_res = gloo_net::http::Request::get("/api/auth/check").send().await;
|
||||||
|
|
||||||
|
match auth_res {
|
||||||
|
Ok(resp) => {
|
||||||
|
if resp.status() == 200 {
|
||||||
|
logging::log!("Authenticated!");
|
||||||
|
|
||||||
|
// Parse user info
|
||||||
|
if let Ok(user_info) = resp.json::<UserResponse>().await {
|
||||||
|
if let Some(store) = use_context::<crate::store::TorrentStore>() {
|
||||||
|
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_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() {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
use leptos::*;
|
use leptos::*;
|
||||||
use crate::api;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct LoginRequest {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
remember_me: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Login() -> impl IntoView {
|
pub fn Login() -> impl IntoView {
|
||||||
@@ -16,31 +23,35 @@ 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 {
|
||||||
match api::auth::login(&username, &password, remember_me).await {
|
let req = LoginRequest {
|
||||||
Ok(_) => {
|
username: username.get(),
|
||||||
logging::log!("Login successful, redirecting...");
|
password: password.get(),
|
||||||
let _ = window().location().set_href("/");
|
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...");
|
||||||
|
// 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!("Login failed: {:?}", e);
|
logging::error!("Network error: {}", e);
|
||||||
let msg = match e {
|
set_error.set(Some("Bağlantı hatası".to_string()));
|
||||||
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);
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
use leptos::*;
|
use leptos::*;
|
||||||
use crate::api;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SetupRequest {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Setup() -> impl IntoView {
|
pub fn Setup() -> impl IntoView {
|
||||||
@@ -29,18 +35,29 @@ pub fn Setup() -> impl IntoView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let username = username.get();
|
|
||||||
let password = pass;
|
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::setup::setup(&username, &password).await {
|
let req = SetupRequest {
|
||||||
Ok(_) => {
|
username: username.get(),
|
||||||
logging::log!("Setup completed successfully, redirecting...");
|
password: pass,
|
||||||
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(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);
|
||||||
|
|||||||
@@ -1,52 +1,56 @@
|
|||||||
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.with(|map| map.len());
|
let total_count = move || store.torrents.get().len();
|
||||||
let downloading_count = move || {
|
let downloading_count = move || {
|
||||||
store.torrents.with(|map| {
|
store
|
||||||
map.values()
|
.torrents
|
||||||
.filter(|t| t.status == shared::TorrentStatus::Downloading)
|
.get()
|
||||||
.count()
|
.iter()
|
||||||
})
|
.filter(|t| t.status == shared::TorrentStatus::Downloading)
|
||||||
|
.count()
|
||||||
};
|
};
|
||||||
let seeding_count = move || {
|
let seeding_count = move || {
|
||||||
store.torrents.with(|map| {
|
store
|
||||||
map.values()
|
.torrents
|
||||||
.filter(|t| t.status == shared::TorrentStatus::Seeding)
|
.get()
|
||||||
.count()
|
.iter()
|
||||||
})
|
.filter(|t| t.status == shared::TorrentStatus::Seeding)
|
||||||
|
.count()
|
||||||
};
|
};
|
||||||
let completed_count = move || {
|
let completed_count = move || {
|
||||||
store.torrents.with(|map| {
|
store
|
||||||
map.values()
|
.torrents
|
||||||
.filter(|t| {
|
.get()
|
||||||
t.status == shared::TorrentStatus::Seeding
|
.iter()
|
||||||
|| (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0)
|
.filter(|t| {
|
||||||
})
|
t.status == shared::TorrentStatus::Seeding
|
||||||
.count()
|
|| (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0)
|
||||||
})
|
})
|
||||||
|
.count()
|
||||||
};
|
};
|
||||||
let paused_count = move || {
|
let paused_count = move || {
|
||||||
store.torrents.with(|map| {
|
store
|
||||||
map.values()
|
.torrents
|
||||||
.filter(|t| t.status == shared::TorrentStatus::Paused)
|
.get()
|
||||||
.count()
|
.iter()
|
||||||
})
|
.filter(|t| t.status == shared::TorrentStatus::Paused)
|
||||||
|
.count()
|
||||||
};
|
};
|
||||||
let inactive_count = move || {
|
let inactive_count = move || {
|
||||||
store.torrents.with(|map| {
|
store
|
||||||
map.values()
|
.torrents
|
||||||
.filter(|t| {
|
.get()
|
||||||
t.status == shared::TorrentStatus::Paused
|
.iter()
|
||||||
|| t.status == shared::TorrentStatus::Error
|
.filter(|t| {
|
||||||
})
|
t.status == shared::TorrentStatus::Paused
|
||||||
.count()
|
|| t.status == shared::TorrentStatus::Error
|
||||||
})
|
})
|
||||||
|
.count()
|
||||||
};
|
};
|
||||||
|
|
||||||
let close_drawer = move || {
|
let close_drawer = move || {
|
||||||
@@ -72,8 +76,12 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
|
|
||||||
let handle_logout = move |_| {
|
let handle_logout = move |_| {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if api::auth::logout().await.is_ok() {
|
let client = gloo_net::http::Request::post("/api/auth/logout");
|
||||||
let _ = window().location().set_href("/login");
|
if let Ok(resp) = client.send().await {
|
||||||
|
if resp.ok() {
|
||||||
|
// Force full reload to clear state
|
||||||
|
let _ = window().location().set_href("/login");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use leptos::*;
|
|||||||
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,23 +60,38 @@ 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 {
|
||||||
if let Err(e) = api::settings::set_global_limits(&req).await {
|
let req_body = if limit_type == "down" {
|
||||||
logging::error!("Failed to set limit: {:?}", e);
|
GlobalLimitRequest {
|
||||||
|
max_download_rate: Some(val),
|
||||||
|
max_upload_rate: None,
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logging::log!("Limit set successfully");
|
GlobalLimitRequest {
|
||||||
|
max_download_rate: None,
|
||||||
|
max_upload_rate: Some(val),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let client =
|
||||||
|
gloo_net::http::Request::post("/api/settings/global-limits").json(&req_body);
|
||||||
|
|
||||||
|
match client {
|
||||||
|
Ok(req) => match req.send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if !resp.ok() {
|
||||||
|
logging::error!(
|
||||||
|
"Failed to set limit: {} {}",
|
||||||
|
resp.status(),
|
||||||
|
resp.status_text()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logging::log!("Limit set successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => logging::error!("Network error setting limit: {}", e),
|
||||||
|
},
|
||||||
|
Err(e) => logging::error!("Failed to create request: {}", e),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +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::{AddTorrentRequest, NotificationLevel};
|
||||||
use shared::NotificationLevel;
|
|
||||||
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
@@ -18,6 +17,7 @@ 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,22 +35,44 @@ 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 {
|
||||||
match api::torrent::add(&uri_val).await {
|
let req_body = AddTorrentRequest { uri: uri_val };
|
||||||
Ok(_) => {
|
|
||||||
logging::log!("Torrent added successfully");
|
match gloo_net::http::Request::post("/api/torrents/add")
|
||||||
show_toast_with_signal(notifications, NotificationLevel::Success, "Torrent eklendi");
|
.json(&req_body)
|
||||||
set_loading.set(false);
|
{
|
||||||
if let Some(dialog) = dialog_ref.get() {
|
Ok(req) => {
|
||||||
dialog.close();
|
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!("Failed to add torrent: {:?}", e);
|
logging::error!("Serialization error: {}", e);
|
||||||
show_toast_with_signal(notifications, NotificationLevel::Error, "Torrent eklenemedi");
|
show_toast_with_signal(notifications, NotificationLevel::Error, "İstek hatası");
|
||||||
set_error_msg.set(Some(format!("Error: {:?}", e)));
|
set_error_msg.set(Some(format!("Request Error: {}", e)));
|
||||||
set_loading.set(false);
|
set_loading.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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 {
|
||||||
@@ -81,77 +80,74 @@ 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);
|
||||||
|
|
||||||
// Get sorted and filtered hashes only
|
let filtered_torrents = move || {
|
||||||
let filtered_hashes = move || {
|
let mut torrents = store
|
||||||
store.torrents.with(|map| {
|
.torrents
|
||||||
let mut torrents: Vec<&shared::Torrent> = map
|
.get()
|
||||||
.values()
|
.into_iter()
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
SortColumn::AddedDate => a.added_date.cmp(&b.added_date),
|
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)
|
||||||
|
} // 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 {
|
|
||||||
cmp.reverse()
|
|
||||||
} else {
|
|
||||||
cmp
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
|
let matches_search = if search.is_empty() {
|
||||||
})
|
true
|
||||||
|
} else {
|
||||||
|
t.name.to_lowercase().contains(&search)
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
};
|
};
|
||||||
|
|
||||||
let handle_sort = move |col: SortColumn| {
|
let handle_sort = move |col: SortColumn| {
|
||||||
@@ -205,33 +201,54 @@ 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 result = match action.as_str() {
|
let action_req = if action == "delete_with_data" {
|
||||||
"delete" => api::torrent::delete(&hash).await,
|
"delete_with_data"
|
||||||
"delete_with_data" => api::torrent::delete_with_data(&hash).await,
|
} else {
|
||||||
"start" => api::torrent::start(&hash).await,
|
&action
|
||||||
"stop" => api::torrent::stop(&hash).await,
|
|
||||||
_ => api::torrent::action(&hash, &action).await,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
let req_body = shared::TorrentActionRequest {
|
||||||
Ok(_) => {
|
hash: hash.clone(),
|
||||||
logging::log!("Action {} executed successfully", action);
|
action: action_req.to_string(),
|
||||||
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!("Action failed: {:?}", e);
|
logging::error!("Failed to serialize request: {}", e);
|
||||||
show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: {:?}", error_msg, e));
|
show_toast_with_signal(notifications, NotificationLevel::Error, error_msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -256,7 +273,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">"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}</div>
|
<div class="flex items-center">"Down 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>
|
||||||
@@ -270,231 +287,124 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<For
|
{move || filtered_torrents().into_iter().map(|t| {
|
||||||
each=move || filtered_hashes()
|
let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" };
|
||||||
key=|hash| hash.clone()
|
let status_str = format!("{:?}", t.status);
|
||||||
children={
|
let status_class = match t.status {
|
||||||
let handle_context_menu = handle_context_menu.clone();
|
shared::TorrentStatus::Seeding => "text-success",
|
||||||
move |hash| {
|
shared::TorrentStatus::Downloading => "text-primary",
|
||||||
view! {
|
shared::TorrentStatus::Paused => "text-warning",
|
||||||
<TorrentRow
|
shared::TorrentStatus::Error => "text-error",
|
||||||
hash=hash.clone()
|
_ => "text-base-content/50"
|
||||||
selected_hash=selected_hash
|
};
|
||||||
set_selected_hash=set_selected_hash
|
let t_hash = t.hash.clone();
|
||||||
on_context_menu=handle_context_menu.clone()
|
let t_hash_click = t.hash.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 cursor-pointer">
|
<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 cursor-default">
|
<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>
|
||||||
|
|
||||||
<details class="dropdown dropdown-end" node_ref=sort_details_ref>
|
<details class="dropdown dropdown-end" node_ref=sort_details_ref>
|
||||||
<summary class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal list-none [&::-webkit-details-marker]:hidden cursor-pointer">
|
<summary class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal list-none [&::-webkit-details-marker]:hidden cursor-pointer">
|
||||||
<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">
|
<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">
|
||||||
<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>
|
||||||
<span class="pointer-events-none">"Sort"</span>
|
<span class="pointer-events-none">"Sort"</span>
|
||||||
</summary>
|
</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">
|
<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>
|
<li class="menu-title px-2 py-1 opacity-50 text-[10px] uppercase font-bold">"Sort By"</li>
|
||||||
{
|
{
|
||||||
let columns = vec![
|
let columns = vec![
|
||||||
(SortColumn::Name, "Name"),
|
(SortColumn::Name, "Name"),
|
||||||
(SortColumn::Size, "Size"),
|
(SortColumn::Size, "Size"),
|
||||||
(SortColumn::Progress, "Progress"),
|
(SortColumn::Progress, "Progress"),
|
||||||
(SortColumn::Status, "Status"),
|
(SortColumn::Status, "Status"),
|
||||||
(SortColumn::DownSpeed, "DL Speed"),
|
(SortColumn::DownSpeed, "Down Speed"),
|
||||||
(SortColumn::UpSpeed, "Up Speed"),
|
(SortColumn::UpSpeed, "Up Speed"),
|
||||||
(SortColumn::ETA, "ETA"),
|
(SortColumn::ETA, "ETA"),
|
||||||
(SortColumn::AddedDate, "Date"),
|
(SortColumn::AddedDate, "Date"),
|
||||||
];
|
];
|
||||||
|
|
||||||
columns.into_iter().map(|(col, label)| {
|
columns.into_iter().map(|(col, label)| {
|
||||||
let is_active = move || sort_col.get() == col;
|
let is_active = move || sort_col.get() == col;
|
||||||
let current_dir = move || sort_dir.get();
|
let current_dir = move || sort_dir.get();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
type="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:click=move |_| {
|
on:click=move |_| {
|
||||||
handle_sort(col);
|
handle_sort(col);
|
||||||
if let Some(el) = sort_details_ref.get_untracked() {
|
if let Some(el) = sort_details_ref.get_untracked() {
|
||||||
el.set_open(false);
|
el.set_open(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
<Show when=is_active fallback=|| ()>
|
<Show when=is_active fallback=|| ()>
|
||||||
<span class="opacity-70 text-[10px]">
|
<span class="opacity-70 text-[10px]">
|
||||||
{move || match current_dir() {
|
{move || match current_dir() {
|
||||||
SortDirection::Ascending => "▲",
|
SortDirection::Ascending => "▲",
|
||||||
SortDirection::Descending => "▼",
|
SortDirection::Descending => "▼",
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
}).collect::<Vec<_>>()
|
}).collect::<Vec<_>>()
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
<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();
|
|
||||||
|
|
||||||
|
<div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3 cursor-pointer"> {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 {
|
||||||
@@ -504,8 +414,10 @@ fn TorrentCard(
|
|||||||
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);
|
||||||
@@ -547,21 +459,15 @@ fn TorrentCard(
|
|||||||
view! {
|
view! {
|
||||||
<div
|
<div
|
||||||
class=move || {
|
class=move || {
|
||||||
let base = "card card-compact bg-base-100 shadow-sm border border-base-200 transition-transform active:scale-[0.99] select-none cursor-pointer";
|
"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();
|
||||||
let on_context_menu = on_context_menu.clone();
|
move |e: web_sys::MouseEvent| handle_context_menu(e, t_hash.clone())
|
||||||
move |e: web_sys::MouseEvent| on_context_menu(e, t_hash.clone())
|
|
||||||
}
|
}
|
||||||
on:click={
|
on:click={
|
||||||
let t_hash = t_hash.clone();
|
let t_hash = t_hash_click.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
|
||||||
@@ -606,8 +512,19 @@ fn TorrentCard(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}).collect::<Vec<_>>()}
|
||||||
}
|
</div>
|
||||||
</Show>
|
</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>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -113,11 +113,9 @@ impl FilterStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct TorrentStore {
|
pub struct TorrentStore {
|
||||||
pub torrents: RwSignal<HashMap<String, Torrent>>,
|
pub torrents: RwSignal<Vec<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>,
|
||||||
@@ -126,7 +124,7 @@ pub struct TorrentStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn provide_torrent_store() {
|
pub fn provide_torrent_store() {
|
||||||
let torrents = create_rw_signal(HashMap::new());
|
let torrents = create_rw_signal(Vec::<Torrent>::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());
|
||||||
@@ -195,30 +193,12 @@ 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.update(|map| {
|
torrents.set(list);
|
||||||
// 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(|map| {
|
torrents.update(|list| {
|
||||||
if let Some(t) = map.get_mut(&update.hash) {
|
if let Some(t) = list.iter_mut().find(|t| t.hash == update.hash)
|
||||||
|
{
|
||||||
if let Some(name) = update.name {
|
if let Some(name) = update.name {
|
||||||
t.name = name;
|
t.name = name;
|
||||||
}
|
}
|
||||||
@@ -334,21 +314,21 @@ use serde::{Deserialize, Serialize};
|
|||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct PushSubscriptionData {
|
struct PushSubscriptionData {
|
||||||
pub endpoint: String,
|
endpoint: String,
|
||||||
pub keys: PushKeys,
|
keys: PushKeys,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct PushKeys {
|
struct PushKeys {
|
||||||
pub p256dh: String,
|
p256dh: String,
|
||||||
pub auth: String,
|
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 crate::api;
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
// 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");
|
||||||
@@ -367,19 +347,36 @@ pub async fn subscribe_to_push_notifications() {
|
|||||||
|
|
||||||
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 = match api::push::get_public_key().await {
|
let public_key_response = match Request::get("/api/push/public-key").send().await {
|
||||||
Ok(key) => key,
|
Ok(resp) => resp,
|
||||||
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
|
||||||
@@ -498,8 +495,23 @@ 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)
|
||||||
if let Err(e) = api::push::subscribe(&subscription_data).await {
|
let response = match Request::post("/api/push/subscribe")
|
||||||
log::error!("Failed to send subscription to backend: {:?}", e);
|
.json(&subscription_data)
|
||||||
|
.expect("serialization should succeed")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to send subscription to backend: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if response.ok() {
|
||||||
|
log::info!("Successfully subscribed to push notifications");
|
||||||
|
} else {
|
||||||
|
log::error!("Backend rejected push subscription: {:?}", response.status());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user