Compare commits

..

2 Commits

Author SHA1 Message Date
spinline
3ffc7576a0 feat: add centralized API service layer for frontend
All checks were successful
Build MIPS Binary / build (push) Successful in 4m24s
- 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:04:24 +03:00
spinline
ce10c5dfb2 refactor: replace magic indices with RtorrentField enum for type-safe parsing
All checks were successful
Build MIPS Binary / build (push) Successful in 4m22s
2026-02-08 22:50:26 +03:00
8 changed files with 353 additions and 484 deletions

33
Cargo.lock generated
View File

@@ -310,6 +310,7 @@ dependencies = [
"serde_json",
"shared",
"sqlx",
"strum",
"thiserror 2.0.18",
"time",
"tokio",
@@ -544,7 +545,7 @@ version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.114",
@@ -1537,6 +1538,12 @@ dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
@@ -3704,7 +3711,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [
"dotenvy",
"either",
"heck",
"heck 0.5.0",
"hex",
"once_cell",
"proc-macro2",
@@ -3847,6 +3854,28 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "subtle"
version = "2.6.1"

View File

@@ -42,3 +42,4 @@ anyhow = "1.0.101"
time = { version = "0.3.47", features = ["serde", "formatting", "parsing"] }
tower_governor = "0.8.0"
governor = "0.10.4"
strum = { version = "0.25", features = ["derive", "strum_macros"] }

View File

@@ -45,7 +45,6 @@ pub struct AppState {
pub db: db::Db,
#[cfg(feature = "push-notifications")]
pub push_store: push::PushSubscriptionStore,
pub notify_poll: Arc<tokio::sync::Notify>,
}
async fn auth_middleware(
@@ -337,8 +336,6 @@ async fn main() {
#[cfg(not(feature = "push-notifications"))]
let push_store = ();
let notify_poll = Arc::new(tokio::sync::Notify::new());
let app_state = AppState {
tx: tx.clone(),
event_bus: event_bus.clone(),
@@ -346,7 +343,6 @@ async fn main() {
db: db.clone(),
#[cfg(feature = "push-notifications")]
push_store,
notify_poll: notify_poll.clone(),
};
// Spawn background task to poll rTorrent
@@ -355,7 +351,6 @@ async fn main() {
let socket_path = args.socket.clone(); // Clone for background task
#[cfg(feature = "push-notifications")]
let push_store_clone = app_state.push_store.clone();
let notify_poll_clone = notify_poll.clone();
tokio::spawn(async move {
let client = xmlrpc::RtorrentClient::new(&socket_path);
@@ -364,14 +359,6 @@ async fn main() {
let mut backoff_duration = Duration::from_secs(1);
loop {
// Determine polling interval based on active clients
let active_clients = event_bus_tx.receiver_count();
let loop_interval = if active_clients > 0 {
Duration::from_secs(1)
} else {
Duration::from_secs(30)
};
// 1. Fetch Torrents
let torrents_result = sse::fetch_torrents(&client).await;
@@ -442,14 +429,6 @@ async fn main() {
}
previous_torrents = new_torrents;
// Success case: wait for the determined interval OR a wakeup notification
tokio::select! {
_ = tokio::time::sleep(loop_interval) => {},
_ = notify_poll_clone.notified() => {
tracing::debug!("Background loop awakened by new client connection");
}
}
}
Err(e) => {
tracing::error!("Error fetching torrents in background: {}", e);
@@ -470,15 +449,20 @@ async fn main() {
"Backoff: Sleeping for {:?} due to rTorrent error.",
backoff_duration
);
tokio::time::sleep(backoff_duration).await;
}
}
// Handle Stats
if let Ok(stats) = stats_result {
let _ = event_bus_tx.send(AppEvent::Stats(stats));
match stats_result {
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;
}
});

View File

@@ -11,8 +11,6 @@ pub enum ScgiError {
#[allow(dead_code)]
#[error("Protocol Error: {0}")]
Protocol(String),
#[error("Timeout: SCGI request took too long")]
Timeout,
}
pub struct ScgiRequest {
@@ -80,30 +78,20 @@ impl ScgiRequest {
}
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 data = request.encode();
stream.write_all(&data).await?;
let mut stream = UnixStream::connect(socket_path).await?;
let data = request.encode();
stream.write_all(&data).await?;
let mut response = Vec::new();
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 mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let double_newline = b"\r\n\r\n";
let mut response_vec = response;
if let Some(pos) = response_vec
if let Some(pos) = response
.windows(double_newline.len())
.position(|window| window == double_newline)
{
Ok(Bytes::from(
response_vec.split_off(pos + double_newline.len()),
))
Ok(Bytes::from(response.split_off(pos + double_newline.len())))
} else {
Ok(Bytes::from(response_vec))
Ok(Bytes::from(response))
}
}

View File

@@ -7,95 +7,80 @@ use axum::response::sse::{Event, Sse};
use futures::stream::{self, Stream};
use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus};
use std::convert::Infallible;
use strum::{Display, EnumString};
use tokio_stream::StreamExt;
// Field definitions to keep query and parser in sync
mod fields {
pub const IDX_HASH: usize = 0;
pub const CMD_HASH: &str = "d.hash=";
pub const IDX_NAME: usize = 1;
pub const CMD_NAME: &str = "d.name=";
pub const IDX_SIZE: usize = 2;
pub const CMD_SIZE: &str = "d.size_bytes=";
pub const IDX_COMPLETED: usize = 3;
pub const CMD_COMPLETED: &str = "d.bytes_done=";
pub const IDX_DOWN_RATE: usize = 4;
pub const CMD_DOWN_RATE: &str = "d.down.rate=";
pub const IDX_UP_RATE: usize = 5;
pub const CMD_UP_RATE: &str = "d.up.rate=";
pub const IDX_STATE: usize = 6;
pub const CMD_STATE: &str = "d.state=";
pub const IDX_COMPLETE: usize = 7;
pub const CMD_COMPLETE: &str = "d.complete=";
pub const IDX_MESSAGE: usize = 8;
pub const CMD_MESSAGE: &str = "d.message=";
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=";
#[derive(Debug, Clone, Copy, EnumString, Display, PartialEq)]
enum RtorrentField {
#[strum(serialize = "d.hash=")]
Hash,
#[strum(serialize = "d.name=")]
Name,
#[strum(serialize = "d.size_bytes=")]
Size,
#[strum(serialize = "d.bytes_done=")]
Completed,
#[strum(serialize = "d.down.rate=")]
DownRate,
#[strum(serialize = "d.up.rate=")]
UpRate,
#[strum(serialize = "d.state=")]
State,
#[strum(serialize = "d.complete=")]
Complete,
#[strum(serialize = "d.message=")]
Message,
#[strum(serialize = "d.left_bytes=")]
LeftBytes,
#[strum(serialize = "d.creation_date=")]
CreationDate,
#[strum(serialize = "d.hashing=")]
Hashing,
#[strum(serialize = "d.custom1=")]
Label,
}
use fields::*;
// Constants for rTorrent fields to ensure query and parser stay in sync
const RTORRENT_FIELDS: &[&str] = &[
"", // Ignored by multicall pattern
"main", // View
CMD_HASH,
CMD_NAME,
CMD_SIZE,
CMD_COMPLETED,
CMD_DOWN_RATE,
CMD_UP_RATE,
CMD_STATE,
CMD_COMPLETE,
CMD_MESSAGE,
CMD_LEFT_BYTES,
CMD_CREATION_DATE,
CMD_HASHING,
CMD_LABEL,
const RTORRENT_FIELDS: &[RtorrentField] = &[
RtorrentField::Hash,
RtorrentField::Name,
RtorrentField::Size,
RtorrentField::Completed,
RtorrentField::DownRate,
RtorrentField::UpRate,
RtorrentField::State,
RtorrentField::Complete,
RtorrentField::Message,
RtorrentField::LeftBytes,
RtorrentField::CreationDate,
RtorrentField::Hashing,
RtorrentField::Label,
];
fn parse_long(s: Option<&String>) -> i64 {
s.map(|v| v.parse().unwrap_or(0)).unwrap_or(0)
fn get_field_value(row: &Vec<String>, field: RtorrentField) -> String {
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 {
s.cloned().unwrap_or_default()
fn parse_long(s: &str) -> i64 {
s.parse().unwrap_or(0)
}
/// Converts a raw row of strings from rTorrent XML-RPC into a generic Torrent struct
fn from_rtorrent_row(row: Vec<String>) -> Torrent {
let hash = parse_string(row.get(IDX_HASH));
let name = parse_string(row.get(IDX_NAME));
let size = parse_long(row.get(IDX_SIZE));
let completed = parse_long(row.get(IDX_COMPLETED));
let down_rate = parse_long(row.get(IDX_DOWN_RATE));
let up_rate = parse_long(row.get(IDX_UP_RATE));
fn from_rtorrent_row(row: &Vec<String>) -> Torrent {
let hash = get_field_value(row, RtorrentField::Hash);
let name = get_field_value(row, RtorrentField::Name);
let size = parse_long(&get_field_value(row, RtorrentField::Size));
let completed = parse_long(&get_field_value(row, RtorrentField::Completed));
let down_rate = parse_long(&get_field_value(row, RtorrentField::DownRate));
let up_rate = parse_long(&get_field_value(row, RtorrentField::UpRate));
let state = parse_long(row.get(IDX_STATE));
let is_complete = parse_long(row.get(IDX_COMPLETE));
let message = parse_string(row.get(IDX_MESSAGE));
let left_bytes = parse_long(row.get(IDX_LEFT_BYTES));
let added_date = parse_long(row.get(IDX_CREATION_DATE));
let is_hashing = parse_long(row.get(IDX_HASHING));
let label_raw = parse_string(row.get(IDX_LABEL));
let state = parse_long(&get_field_value(row, RtorrentField::State));
let is_complete = parse_long(&get_field_value(row, RtorrentField::Complete));
let message = get_field_value(row, RtorrentField::Message);
let left_bytes = parse_long(&get_field_value(row, RtorrentField::LeftBytes));
let added_date = parse_long(&get_field_value(row, RtorrentField::CreationDate));
let is_hashing = parse_long(&get_field_value(row, RtorrentField::Hashing));
let label_raw = get_field_value(row, RtorrentField::Label);
let label = if label_raw.is_empty() {
None
@@ -146,7 +131,10 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
}
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", &params).await?;
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 torrents = rows.into_iter().map(from_rtorrent_row).collect();
let torrents = rows.iter().map(from_rtorrent_row).collect();
Ok(torrents)
}
@@ -195,9 +183,6 @@ pub async fn fetch_global_stats(client: &RtorrentClient) -> Result<GlobalStats,
pub async fn sse_handler(
State(state): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
// Notify background worker to wake up and poll immediately
state.notify_poll.notify_one();
// Get initial value synchronously (from the watch channel's current state)
let initial_rx = state.tx.subscribe();
let initial_torrents = initial_rx.borrow().clone();

View File

@@ -6,47 +6,52 @@ use crate::api;
pub fn Sidebar() -> impl IntoView {
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 || {
store.torrents.with(|map| {
map.values()
.filter(|t| t.status == shared::TorrentStatus::Downloading)
.count()
})
store
.torrents
.get()
.iter()
.filter(|t| t.status == shared::TorrentStatus::Downloading)
.count()
};
let seeding_count = move || {
store.torrents.with(|map| {
map.values()
.filter(|t| t.status == shared::TorrentStatus::Seeding)
.count()
})
store
.torrents
.get()
.iter()
.filter(|t| t.status == shared::TorrentStatus::Seeding)
.count()
};
let completed_count = move || {
store.torrents.with(|map| {
map.values()
.filter(|t| {
t.status == shared::TorrentStatus::Seeding
|| (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0)
})
.count()
})
store
.torrents
.get()
.iter()
.filter(|t| {
t.status == shared::TorrentStatus::Seeding
|| (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0)
})
.count()
};
let paused_count = move || {
store.torrents.with(|map| {
map.values()
.filter(|t| t.status == shared::TorrentStatus::Paused)
.count()
})
store
.torrents
.get()
.iter()
.filter(|t| t.status == shared::TorrentStatus::Paused)
.count()
};
let inactive_count = move || {
store.torrents.with(|map| {
map.values()
.filter(|t| {
t.status == shared::TorrentStatus::Paused
|| t.status == shared::TorrentStatus::Error
})
.count()
})
store
.torrents
.get()
.iter()
.filter(|t| {
t.status == shared::TorrentStatus::Paused
|| t.status == shared::TorrentStatus::Error
})
.count()
};
let close_drawer = move || {

View File

@@ -81,77 +81,74 @@ pub fn TorrentTable() -> impl IntoView {
let sort_col = create_rw_signal(SortColumn::AddedDate);
let sort_dir = create_rw_signal(SortDirection::Descending);
// Get sorted and filtered hashes only
let filtered_hashes = move || {
store.torrents.with(|map| {
let mut torrents: Vec<&shared::Torrent> = map
.values()
.filter(|t| {
let filter = store.filter.get();
let search = store.search_query.get().to_lowercase();
let filtered_torrents = move || {
let mut torrents = store
.torrents
.get()
.into_iter()
.filter(|t| {
let filter = store.filter.get();
let search = store.search_query.get().to_lowercase();
let matches_filter = match filter {
crate::store::FilterStatus::All => true,
crate::store::FilterStatus::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)
let matches_filter = match filter {
crate::store::FilterStatus::All => true,
crate::store::FilterStatus::Downloading => {
t.status == shared::TorrentStatus::Downloading
}
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| {
@@ -256,7 +253,7 @@ pub fn TorrentTable() -> impl IntoView {
<div class="flex items-center">"Status" {move || sort_arrow(SortColumn::Status)}</div>
</th>
<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 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>
@@ -270,231 +267,124 @@ pub fn TorrentTable() -> impl IntoView {
</tr>
</thead>
<tbody>
<For
each=move || filtered_hashes()
key=|hash| hash.clone()
children={
let handle_context_menu = handle_context_menu.clone();
move |hash| {
view! {
<TorrentRow
hash=hash.clone()
selected_hash=selected_hash
set_selected_hash=set_selected_hash
on_context_menu=handle_context_menu.clone()
/>
{move || filtered_torrents().into_iter().map(|t| {
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"
};
let t_hash = t.hash.clone();
let t_hash_click = t.hash.clone();
let is_selected_fn = move || {
selected_hash.get() == Some(t_hash.clone())
};
view! {
<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>
</table>
</div>
<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">
<span class="text-xs font-bold opacity-50 uppercase tracking-wider">"Torrents"</span>
<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">
<span class="text-xs font-bold opacity-50 uppercase tracking-wider">"Torrents"</span>
<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">
<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" />
</svg>
<span class="pointer-events-none">"Sort"</span>
</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"),
];
<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">
<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" />
</svg>
<span class="pointer-events-none">"Sort"</span>
</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, "Down 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();
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<_>>()
}
</ul>
</details>
</div>
<div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3 cursor-pointer">
<For
each=move || filtered_hashes()
key=|hash| hash.clone()
children={
let handle_context_menu = handle_context_menu.clone();
move |hash| {
view! {
<TorrentCard
hash=hash.clone()
selected_hash=selected_hash
set_selected_hash=set_selected_hash
set_menu_position=set_menu_position
set_menu_visible=set_menu_visible
on_context_menu=handle_context_menu.clone()
/>
}
}
}
/>
</div>
</div>
<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();
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<_>>()
}
</ul>
</details>
</div>
<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 status_str = format!("{:?}", t.status);
let status_badge_class = match t.status {
@@ -504,8 +394,10 @@ fn TorrentCard(
shared::TorrentStatus::Error => "badge-error badge-soft",
_ => "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(
move |pos: (i32, i32)| {
set_menu_position.set(pos);
@@ -547,21 +439,15 @@ fn TorrentCard(
view! {
<div
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";
if selected_hash.get() == Some(t_hash_class.clone()) {
format!("{} ring-2 ring-primary ring-inset", base)
} else {
base.to_string()
}
"card card-compact bg-base-100 shadow-sm border border-base-200 transition-transform active:scale-[0.99] select-none cursor-pointer"
}
style="user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;"
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())
let t_hash = t.hash.clone();
move |e: web_sys::MouseEvent| handle_context_menu(e, t_hash.clone())
}
on:click={
let t_hash = t_hash.clone();
let t_hash = t_hash_click.clone();
move |_| set_selected_hash.set(Some(t_hash.clone()))
}
on:touchstart=handle_touchstart
@@ -606,8 +492,19 @@ fn TorrentCard(
</div>
</div>
}
}
}
</Show>
}).collect::<Vec<_>>()}
</div>
</div>
<Show when=move || menu_visible.get() fallback=|| ()>
<crate::components::context_menu::ContextMenu
visible=true
position=menu_position.get()
torrent_hash=selected_hash.get().unwrap_or_default()
on_close=Callback::from(move |_| set_menu_visible.set(false))
on_action=Callback::from(on_action)
/>
</Show>
</div>
}
}

View File

@@ -113,11 +113,9 @@ impl FilterStatus {
}
}
use std::collections::HashMap;
#[derive(Clone, Copy, Debug)]
pub struct TorrentStore {
pub torrents: RwSignal<HashMap<String, Torrent>>,
pub torrents: RwSignal<Vec<Torrent>>,
pub filter: RwSignal<FilterStatus>,
pub search_query: RwSignal<String>,
pub global_stats: RwSignal<GlobalStats>,
@@ -126,7 +124,7 @@ pub struct TorrentStore {
}
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 search_query = create_rw_signal(String::new());
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) {
match event {
AppEvent::FullList { torrents: 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);
}
}
});
torrents.set(list);
}
AppEvent::Update(update) => {
torrents.update(|map| {
if let Some(t) = map.get_mut(&update.hash) {
torrents.update(|list| {
if let Some(t) = list.iter_mut().find(|t| t.hash == update.hash)
{
if let Some(name) = update.name {
t.name = name;
}