feat: Initial release with MIPS support
This commit is contained in:
45
.github/workflows/build-mips.yml
vendored
Normal file
45
.github/workflows/build-mips.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: Build MIPS Binary
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: mips-unknown-linux-musl
|
||||||
|
|
||||||
|
- name: Install Trunk
|
||||||
|
uses: jetli/trunk-action@v0.4.0
|
||||||
|
with:
|
||||||
|
version: 'latest'
|
||||||
|
|
||||||
|
- name: Build Frontend
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
trunk build --release
|
||||||
|
|
||||||
|
- name: Install Cross
|
||||||
|
run: cargo install cross
|
||||||
|
|
||||||
|
- name: Build Backend (MIPS)
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
cross build --target mips-unknown-linux-musl --release
|
||||||
|
|
||||||
|
- name: Upload Binary
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: vibetorrent-mips
|
||||||
|
path: backend/target/mips-unknown-linux-musl/release/backend
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/target
|
||||||
|
**/.DS_Store
|
||||||
|
*.log
|
||||||
|
result.xml
|
||||||
|
**/node_modules
|
||||||
|
frontend/dist
|
||||||
|
backend.log
|
||||||
2872
Cargo.lock
generated
Normal file
2872
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["backend", "frontend"]
|
||||||
|
resolver = "2"
|
||||||
23
backend/Cargo.toml
Normal file
23
backend/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.7", features = ["macros", "ws"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
tower-http = { version = "0.5", features = ["fs", "trace", "cors"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
tokio-stream = "0.1"
|
||||||
|
bytes = "1"
|
||||||
|
futures = "0.3"
|
||||||
|
quick-xml = { version = "0.31", features = ["serialize"] }
|
||||||
|
# We might need `tokio-util` for codecs if we implement SCGI manually
|
||||||
|
tokio-util = { version = "0.7", features = ["codec", "io"] }
|
||||||
|
clap = { version = "4.4", features = ["derive"] }
|
||||||
|
rust-embed = "8.2"
|
||||||
|
mime_guess = "2.0"
|
||||||
182
backend/src/main.rs
Normal file
182
backend/src/main.rs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
|
||||||
|
mod models;
|
||||||
|
mod scgi;
|
||||||
|
mod sse;
|
||||||
|
mod xmlrpc;
|
||||||
|
|
||||||
|
// fixup modules
|
||||||
|
// remove mm if I didn't create it? I didn't.
|
||||||
|
// I will structure modules correctly.
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use rust_embed::RustEmbed;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{header, StatusCode, Uri},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
Router, Json,
|
||||||
|
};
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use crate::models::AppState;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
/// Path to rTorrent SCGI socket
|
||||||
|
#[arg(short, long, default_value = "/tmp/rtorrent.sock")]
|
||||||
|
socket: String,
|
||||||
|
|
||||||
|
/// Port to listen on
|
||||||
|
#[arg(short, long, default_value_t = 3000)]
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(RustEmbed)]
|
||||||
|
#[folder = "../frontend/dist"]
|
||||||
|
struct Asset;
|
||||||
|
|
||||||
|
async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||||
|
let mut path = uri.path().trim_start_matches('/').to_string();
|
||||||
|
|
||||||
|
if path.is_empty() {
|
||||||
|
path = "index.html".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
match Asset::get(&path) {
|
||||||
|
Some(content) => {
|
||||||
|
let mime = mime_guess::from_path(&path).first_or_octet_stream();
|
||||||
|
([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if path.contains('.') {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
// Fallback to index.html for SPA routing
|
||||||
|
match Asset::get("index.html") {
|
||||||
|
Some(content) => {
|
||||||
|
let mime = mime_guess::from_path("index.html").first_or_octet_stream();
|
||||||
|
([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
|
||||||
|
}
|
||||||
|
None => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/* ... add_torrent_handler ... */
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// initialize tracing
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
// Parse CLI Args
|
||||||
|
let args = Args::parse();
|
||||||
|
println!("Starting VibeTorrent Backend...");
|
||||||
|
println!("Socket: {}", args.socket);
|
||||||
|
println!("Port: {}", args.port);
|
||||||
|
|
||||||
|
// Channel for torrent list updates
|
||||||
|
let (tx, _rx) = watch::channel(vec![]);
|
||||||
|
let tx = Arc::new(tx);
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
tx: tx.clone(),
|
||||||
|
scgi_socket_path: args.socket.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spawn background task to poll rTorrent
|
||||||
|
let tx_clone = tx.clone();
|
||||||
|
let socket_path = args.socket.clone(); // Clone for background task
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let client = xmlrpc::RtorrentClient::new(&socket_path);
|
||||||
|
loop {
|
||||||
|
match sse::fetch_torrents(&client).await {
|
||||||
|
Ok(torrents) => {
|
||||||
|
let _ = tx_clone.send(torrents);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error fetching torrents in background: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/api/events", get(sse::sse_handler))
|
||||||
|
.route("/api/torrents/add", post(add_torrent_handler))
|
||||||
|
.route("/api/torrents/action", post(handle_torrent_action))
|
||||||
|
.fallback(static_handler) // Serve static files for everything else
|
||||||
|
.layer(CorsLayer::permissive())
|
||||||
|
.with_state(app_state);
|
||||||
|
|
||||||
|
let addr = SocketAddr::from(([0, 0, 0, 0], args.port));
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||||
|
println!("Backend listening on {}", addr);
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_torrent_action(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<models::TorrentActionRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
println!("Received action: {} for hash: {}", payload.action, payload.hash);
|
||||||
|
|
||||||
|
// Special handling for delete_with_data
|
||||||
|
if payload.action == "delete_with_data" {
|
||||||
|
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
|
||||||
|
|
||||||
|
// 1. Get Base Path
|
||||||
|
let path_xml = match client.call("d.base_path", &[&payload.hash]).await {
|
||||||
|
Ok(xml) => xml,
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to call rTorrent: {}", e)).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = match xmlrpc::parse_string_response(&path_xml) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse path: {}", e)).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Attempting to delete torrent and data at path: {}", path);
|
||||||
|
if path.trim().is_empty() || path == "/" {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Safety check failed: Path is empty or root").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Erase Torrent first (so rTorrent releases locks?)
|
||||||
|
if let Err(e) = client.call("d.erase", &[&payload.hash]).await {
|
||||||
|
eprintln!("Failed to erase torrent entry: {}", e);
|
||||||
|
// Proceed anyway to delete files? Maybe not.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Delete Files via rTorrent (execute.throw.bg)
|
||||||
|
// Command: rm -rf <path>
|
||||||
|
match client.call("execute.throw.bg", &["", "rm", "-rf", &path]).await {
|
||||||
|
Ok(_) => return (StatusCode::OK, "Torrent and data deleted").into_response(),
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to delete data: {}", e)).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let method = match payload.action.as_str() {
|
||||||
|
"start" => "d.start",
|
||||||
|
"stop" => "d.stop",
|
||||||
|
"delete" => "d.erase",
|
||||||
|
_ => return (StatusCode::BAD_REQUEST, "Invalid action").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match scgi::system_call(&state.scgi_socket_path, method, vec![&payload.hash]).await {
|
||||||
|
Ok(_) => (StatusCode::OK, "Action executed").into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("SCGI error: {:?}", e);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to execute action").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
backend/src/models.rs
Normal file
57
backend/src/models.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Torrent {
|
||||||
|
pub hash: String,
|
||||||
|
pub name: String,
|
||||||
|
pub size: i64,
|
||||||
|
pub completed: i64,
|
||||||
|
pub down_rate: i64,
|
||||||
|
pub up_rate: i64,
|
||||||
|
pub eta: i64,
|
||||||
|
pub percent_complete: f64,
|
||||||
|
pub status: TorrentStatus,
|
||||||
|
pub error_message: String,
|
||||||
|
pub added_date: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct TorrentActionRequest {
|
||||||
|
pub hash: String,
|
||||||
|
pub action: String, // "start", "stop", "delete"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TorrentStatus {
|
||||||
|
Downloading,
|
||||||
|
Seeding,
|
||||||
|
Paused,
|
||||||
|
Error,
|
||||||
|
Checking,
|
||||||
|
Queued,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(tag = "type", content = "data")]
|
||||||
|
pub enum AppEvent {
|
||||||
|
FullList(Vec<Torrent>, u64),
|
||||||
|
Update(TorrentUpdate),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct TorrentUpdate {
|
||||||
|
pub hash: String,
|
||||||
|
// Optional fields for partial updates
|
||||||
|
pub down_rate: Option<i64>,
|
||||||
|
pub up_rate: Option<i64>,
|
||||||
|
pub percent_complete: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub tx: Arc<watch::Sender<Vec<Torrent>>>,
|
||||||
|
pub scgi_socket_path: String,
|
||||||
|
}
|
||||||
144
backend/src/scgi.rs
Normal file
144
backend/src/scgi.rs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
use bytes::{BufMut, Bytes, BytesMut};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ScgiError {
|
||||||
|
Io(std::io::Error),
|
||||||
|
Protocol(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for ScgiError {
|
||||||
|
fn from(err: std::io::Error) -> Self {
|
||||||
|
ScgiError::Io(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ScgiRequest {
|
||||||
|
headers: HashMap<String, String>,
|
||||||
|
body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScgiRequest {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut headers = HashMap::new();
|
||||||
|
headers.insert("SCGI".to_string(), "1".to_string());
|
||||||
|
Self {
|
||||||
|
headers,
|
||||||
|
body: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn header(mut self, key: &str, value: &str) -> Self {
|
||||||
|
self.headers.insert(key.to_string(), value.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn body(mut self, body: Vec<u8>) -> Self {
|
||||||
|
self.body = body;
|
||||||
|
self.headers
|
||||||
|
.insert("CONTENT_LENGTH".to_string(), self.body.len().to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
let mut headers_data = Vec::new();
|
||||||
|
|
||||||
|
// SCGI Spec: The first header must be "CONTENT_LENGTH"
|
||||||
|
// The second header must be "SCGI" with value "1"
|
||||||
|
|
||||||
|
// We handle CONTENT_LENGTH and SCGI explicitly first
|
||||||
|
let content_len = self.body.len().to_string();
|
||||||
|
headers_data.extend_from_slice(b"CONTENT_LENGTH");
|
||||||
|
headers_data.push(0);
|
||||||
|
headers_data.extend_from_slice(content_len.as_bytes());
|
||||||
|
headers_data.push(0);
|
||||||
|
|
||||||
|
headers_data.extend_from_slice(b"SCGI");
|
||||||
|
headers_data.push(0);
|
||||||
|
headers_data.extend_from_slice(b"1");
|
||||||
|
headers_data.push(0);
|
||||||
|
|
||||||
|
// Add remaining headers (excluding the ones we just added if they exist in the map)
|
||||||
|
for (k, v) in &self.headers {
|
||||||
|
if k == "CONTENT_LENGTH" || k == "SCGI" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers_data.extend_from_slice(k.as_bytes());
|
||||||
|
headers_data.push(0);
|
||||||
|
headers_data.extend_from_slice(v.as_bytes());
|
||||||
|
headers_data.push(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let headers_len = headers_data.len();
|
||||||
|
let mut packet = Vec::new();
|
||||||
|
let len_str = headers_len.to_string();
|
||||||
|
packet.extend_from_slice(len_str.as_bytes());
|
||||||
|
packet.push(b':');
|
||||||
|
packet.extend(headers_data);
|
||||||
|
packet.push(b',');
|
||||||
|
packet.extend(&self.body);
|
||||||
|
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_request(
|
||||||
|
socket_path: &str,
|
||||||
|
request: ScgiRequest,
|
||||||
|
) -> Result<Bytes, ScgiError> {
|
||||||
|
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?;
|
||||||
|
|
||||||
|
// The response is usually HTTP-like: headers\r\n\r\nbody
|
||||||
|
// We strictly want the body for XML-RPC
|
||||||
|
// Find double newline
|
||||||
|
let double_newline = b"\r\n\r\n";
|
||||||
|
if let Some(pos) = response
|
||||||
|
.windows(double_newline.len())
|
||||||
|
.position(|window| window == double_newline)
|
||||||
|
{
|
||||||
|
Ok(Bytes::from(response.split_off(pos + double_newline.len())))
|
||||||
|
} else {
|
||||||
|
// Fallback: rTorrent sometimes sends raw XML without headers if configured poorly,
|
||||||
|
// but SCGI usually implies headers.
|
||||||
|
// If we don't find headers, maybe it's all body?
|
||||||
|
// But usually there's at least "Status: 200 OK"
|
||||||
|
// Let's return everything if we can't find the split, or error.
|
||||||
|
// For now, assume everything is body if no headers found might be unsafe,
|
||||||
|
// but valid for simple XML-RPC dumping.
|
||||||
|
Ok(Bytes::from(response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn system_call(
|
||||||
|
socket_path: &str,
|
||||||
|
method: &str,
|
||||||
|
params: Vec<&str>,
|
||||||
|
) -> Result<String, ScgiError> {
|
||||||
|
// Construct XML-RPC payload manually for simplicity
|
||||||
|
// <methodCall><methodName>method</methodName><params><param><value><string>val</string></value></param>...</params></methodCall>
|
||||||
|
let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
||||||
|
xml.push_str(&format!("<methodCall><methodName>{}</methodName><params>", method));
|
||||||
|
for param in params {
|
||||||
|
// Use CDATA for safety with special chars in magnet links
|
||||||
|
xml.push_str(&format!("<param><value><string><![CDATA[{}]]></string></value></param>", param));
|
||||||
|
}
|
||||||
|
xml.push_str("</params></methodCall>");
|
||||||
|
|
||||||
|
println!("Sending XML-RPC Payload: {}", xml); // Debug logging
|
||||||
|
|
||||||
|
let req = ScgiRequest::new().body(xml.clone().into_bytes());
|
||||||
|
let response_bytes = send_request(socket_path, req).await?;
|
||||||
|
let response_str = String::from_utf8_lossy(&response_bytes).to_string();
|
||||||
|
|
||||||
|
// Ideally parse the response, but for actions we just check if it executed without SCGI error
|
||||||
|
// rTorrent usually returns <value><i8>0</i8></value> for success or fault.
|
||||||
|
// For now, returning the raw string is fine for debugging/logging in main.
|
||||||
|
|
||||||
|
Ok(response_str)
|
||||||
|
}
|
||||||
157
backend/src/sse.rs
Normal file
157
backend/src/sse.rs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
use axum::response::sse::{Event, Sse};
|
||||||
|
use futures::stream::{self, Stream};
|
||||||
|
use std::{convert::Infallible, time::Duration};
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
use crate::models::{AppEvent, Torrent};
|
||||||
|
use crate::xmlrpc::{RtorrentClient, parse_multicall_response};
|
||||||
|
|
||||||
|
// Helper (should be moved to utils)
|
||||||
|
fn parse_size(s: &str) -> i64 {
|
||||||
|
s.parse().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_float(s: &str) -> f64 {
|
||||||
|
// rTorrent usually returns integers for bytes done etc.
|
||||||
|
// We might need to handle empty strings.
|
||||||
|
s.parse().unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_torrents(client: &RtorrentClient) -> Result<Vec<Torrent>, String> {
|
||||||
|
// d.multicall2("", "main", ...)
|
||||||
|
let params = vec![
|
||||||
|
"",
|
||||||
|
"main",
|
||||||
|
"d.hash=",
|
||||||
|
"d.name=",
|
||||||
|
"d.size_bytes=",
|
||||||
|
"d.bytes_done=",
|
||||||
|
"d.down.rate=",
|
||||||
|
"d.up.rate=",
|
||||||
|
"d.state=", // 6
|
||||||
|
"d.complete=", // 7
|
||||||
|
"d.message=", // 8
|
||||||
|
"d.left_bytes=", // 9
|
||||||
|
"d.creation_date=", // 10
|
||||||
|
"d.hashing=", // 11
|
||||||
|
];
|
||||||
|
|
||||||
|
match client.call("d.multicall2", ¶ms).await {
|
||||||
|
Ok(xml) => {
|
||||||
|
if xml.trim().is_empty() {
|
||||||
|
return Err("Empty response from SCGI".to_string());
|
||||||
|
}
|
||||||
|
match parse_multicall_response(&xml) {
|
||||||
|
Ok(rows) => {
|
||||||
|
let torrents = rows.into_iter().map(|row| {
|
||||||
|
// row map indexes:
|
||||||
|
// 0: hash, 1: name, 2: size, 3: completed, 4: down_rate, 5: up_rate
|
||||||
|
// 6: state, 7: complete, 8: message, 9: left_bytes, 10: added, 11: hashing
|
||||||
|
|
||||||
|
let hash = row.get(0).cloned().unwrap_or_default();
|
||||||
|
let name = row.get(1).cloned().unwrap_or_default();
|
||||||
|
let size = parse_size(row.get(2).unwrap_or(&"0".to_string()));
|
||||||
|
let completed = parse_size(row.get(3).unwrap_or(&"0".to_string()));
|
||||||
|
let down_rate = parse_size(row.get(4).unwrap_or(&"0".to_string()));
|
||||||
|
let up_rate = parse_size(row.get(5).unwrap_or(&"0".to_string()));
|
||||||
|
|
||||||
|
let state = parse_size(row.get(6).unwrap_or(&"0".to_string()));
|
||||||
|
let is_complete = parse_size(row.get(7).unwrap_or(&"0".to_string()));
|
||||||
|
let message = row.get(8).cloned().unwrap_or_default();
|
||||||
|
let left_bytes = parse_size(row.get(9).unwrap_or(&"0".to_string()));
|
||||||
|
let added_date = parse_size(row.get(10).unwrap_or(&"0".to_string()));
|
||||||
|
let is_hashing = parse_size(row.get(11).unwrap_or(&"0".to_string()));
|
||||||
|
|
||||||
|
let percent_complete = if size > 0 {
|
||||||
|
(completed as f64 / size as f64) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status Logic
|
||||||
|
let status = if !message.is_empty() {
|
||||||
|
crate::models::TorrentStatus::Error
|
||||||
|
} else if is_hashing != 0 {
|
||||||
|
crate::models::TorrentStatus::Checking
|
||||||
|
} else if state == 0 {
|
||||||
|
crate::models::TorrentStatus::Paused
|
||||||
|
} else if is_complete != 0 {
|
||||||
|
crate::models::TorrentStatus::Seeding
|
||||||
|
} else {
|
||||||
|
crate::models::TorrentStatus::Downloading
|
||||||
|
};
|
||||||
|
|
||||||
|
// ETA Logic (seconds)
|
||||||
|
let eta = if down_rate > 0 && left_bytes > 0 {
|
||||||
|
left_bytes / down_rate
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
Torrent {
|
||||||
|
hash,
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
completed,
|
||||||
|
down_rate,
|
||||||
|
up_rate,
|
||||||
|
eta,
|
||||||
|
percent_complete,
|
||||||
|
status,
|
||||||
|
error_message: message,
|
||||||
|
added_date,
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
Ok(torrents)
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
Err(format!("XML Parse Error: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
Err(format!("RPC Error: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use crate::models::AppState;
|
||||||
|
|
||||||
|
pub async fn sse_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||||
|
// Get initial value synchronously (from the watch channel's current state)
|
||||||
|
let initial_rx = state.tx.subscribe();
|
||||||
|
let initial_torrents = initial_rx.borrow().clone();
|
||||||
|
|
||||||
|
let initial_event = {
|
||||||
|
let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
|
||||||
|
let event_data = AppEvent::FullList(initial_torrents, timestamp);
|
||||||
|
match serde_json::to_string(&event_data) {
|
||||||
|
Ok(json) => Event::default().data(json),
|
||||||
|
Err(_) => Event::default().comment("init_error"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stream that yields the initial event once
|
||||||
|
let initial_stream = stream::once(async { Ok::<Event, Infallible>(initial_event) });
|
||||||
|
|
||||||
|
// Stream that waits for subsequent changes
|
||||||
|
let update_stream = stream::unfold(state.tx.subscribe(), |mut rx| async move {
|
||||||
|
if let Err(_) = rx.changed().await {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let torrents = rx.borrow().clone();
|
||||||
|
// println!("Broadcasting SSE update with {} items", torrents.len());
|
||||||
|
let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
|
||||||
|
let event_data = AppEvent::FullList(torrents, timestamp);
|
||||||
|
|
||||||
|
match serde_json::to_string(&event_data) {
|
||||||
|
Ok(json) => Some((Ok::<Event, Infallible>(Event::default().data(json)), rx)),
|
||||||
|
Err(_) => Some((Ok::<Event, Infallible>(Event::default().comment("error")), rx)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Sse::new(initial_stream.chain(update_stream))
|
||||||
|
.keep_alive(axum::response::sse::KeepAlive::default())
|
||||||
|
}
|
||||||
181
backend/src/xmlrpc.rs
Normal file
181
backend/src/xmlrpc.rs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
use crate::scgi::{send_request, ScgiRequest};
|
||||||
|
use quick_xml::events::Event;
|
||||||
|
use quick_xml::reader::Reader;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
// Simple helper to build an XML-RPC method call
|
||||||
|
pub fn build_method_call(method: &str, params: &[&str]) -> String {
|
||||||
|
let mut xml = String::from("<?xml version=\"1.0\"?>\n<methodCall>\n");
|
||||||
|
xml.push_str(&format!("<methodName>{}</methodName>\n<params>\n", method));
|
||||||
|
for param in params {
|
||||||
|
xml.push_str("<param><value><string><![CDATA[");
|
||||||
|
xml.push_str(param);
|
||||||
|
xml.push_str("]]></string></value></param>\n");
|
||||||
|
}
|
||||||
|
xml.push_str("</params>\n</methodCall>");
|
||||||
|
xml
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RtorrentClient {
|
||||||
|
socket_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RtorrentClient {
|
||||||
|
pub fn new(socket_path: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
socket_path: socket_path.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn call(&self, method: &str, params: &[&str]) -> Result<String, String> {
|
||||||
|
let xml = build_method_call(method, params);
|
||||||
|
let req = ScgiRequest::new().body(xml.into_bytes());
|
||||||
|
|
||||||
|
match send_request(&self.socket_path, req).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let s = String::from_utf8_lossy(&bytes).to_string();
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("{:?}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specialized parser for d.multicall2 response
|
||||||
|
// Expected structure:
|
||||||
|
// <methodResponse><params><param><value><array><data>
|
||||||
|
// <value><array><data>
|
||||||
|
// <value><string>HASH</string></value>
|
||||||
|
// <value><string>NAME</string></value>
|
||||||
|
// ...
|
||||||
|
// </data></array></value>
|
||||||
|
// ...
|
||||||
|
// </data></array></value></param></params></methodResponse>
|
||||||
|
|
||||||
|
pub fn parse_multicall_response(xml: &str) -> Result<Vec<Vec<String>>, String> {
|
||||||
|
let mut reader = Reader::from_str(xml);
|
||||||
|
reader.trim_text(true);
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let mut current_row = Vec::new();
|
||||||
|
let mut inside_value = false;
|
||||||
|
let mut current_text = String::new();
|
||||||
|
|
||||||
|
// Loop through events
|
||||||
|
// Strategy: We look for <data> inside the outer array.
|
||||||
|
// The outer array contains values which are arrays (rows).
|
||||||
|
// Each row array contains values (columns).
|
||||||
|
|
||||||
|
// Simplified logic: flatten all <value>... content, but respect structure?
|
||||||
|
// Actually, handling nested arrays properly with a streaming parser is tricky.
|
||||||
|
// Let's rely on the fact that d.multicall2 returns a 2D array.
|
||||||
|
// Depth 0: methodResponse/params/param/value/array/data
|
||||||
|
// Depth 1: value (row) / array / data
|
||||||
|
// Depth 2: value (col) / type (string/i8/i4)
|
||||||
|
|
||||||
|
// We can count <array> depth.
|
||||||
|
|
||||||
|
let mut array_depth = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match reader.read_event_into(&mut buf) {
|
||||||
|
Ok(Event::Start(ref e)) => {
|
||||||
|
match e.name().as_ref() {
|
||||||
|
b"array" => array_depth += 1,
|
||||||
|
b"value" => inside_value = true,
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::End(ref e)) => {
|
||||||
|
match e.name().as_ref() {
|
||||||
|
b"array" => {
|
||||||
|
array_depth -= 1;
|
||||||
|
// If we just finished a row (depth 1 which means the inner array of the main list)
|
||||||
|
if array_depth == 1 {
|
||||||
|
if !current_row.is_empty() {
|
||||||
|
results.push(current_row.clone());
|
||||||
|
current_row.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
b"value" => {
|
||||||
|
inside_value = false;
|
||||||
|
// If we are at depth 2 (inside a column value)
|
||||||
|
if array_depth == 2 && !current_text.is_empty() {
|
||||||
|
current_row.push(current_text.clone());
|
||||||
|
current_text.clear();
|
||||||
|
} else if array_depth == 2 {
|
||||||
|
// Empty value or non-text?
|
||||||
|
// Sometimes values are empty, e.g. empty string
|
||||||
|
// We should push it if we just closed a value at depth 2
|
||||||
|
// But wait, the text event handles the content.
|
||||||
|
// Logic: If we closed value at depth 2, we push the collected text (which might be empty).
|
||||||
|
// To handle empty text correctly, we should clear text at Start(value) or use a flag.
|
||||||
|
if inside_value == false { // we just closed it
|
||||||
|
current_row.push(current_text.clone());
|
||||||
|
current_text.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Text(e)) => {
|
||||||
|
if inside_value && array_depth == 2 {
|
||||||
|
current_text = e.unescape().unwrap().into_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Eof) => break,
|
||||||
|
Err(e) => return Err(format!("Parse error: {:?}", e)),
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a simple string response from a method call
|
||||||
|
// Expected: <methodResponse><params><param><value><string>RESULT</string></value></param></params></methodResponse>
|
||||||
|
pub fn parse_string_response(xml: &str) -> Result<String, String> {
|
||||||
|
let mut reader = Reader::from_str(xml);
|
||||||
|
reader.trim_text(true);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut inside_string = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match reader.read_event_into(&mut buf) {
|
||||||
|
Ok(Event::Start(ref e)) => {
|
||||||
|
if e.name().as_ref() == b"string" {
|
||||||
|
inside_string = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Text(e)) => {
|
||||||
|
if inside_string {
|
||||||
|
result = e.unescape().unwrap().into_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::End(ref e)) => {
|
||||||
|
if e.name().as_ref() == b"string" {
|
||||||
|
inside_string = false;
|
||||||
|
// Assuming only one string in the response which matters
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Eof) => break,
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.is_empty() {
|
||||||
|
// It might be empty string or we didn't find it.
|
||||||
|
// If xml contains "fault", we should verify.
|
||||||
|
if xml.contains("fault") {
|
||||||
|
return Err("RPC Fault detected".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
21
frontend/Cargo.toml
Normal file
21
frontend/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "frontend"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
leptos = { version = "0.6", features = ["csr"] }
|
||||||
|
console_error_panic_hook = "0.1"
|
||||||
|
console_log = "1"
|
||||||
|
log = "0.4"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
gloo-net = "0.5"
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
uuid = { version = "1", features = ["v4", "js"] }
|
||||||
|
futures = "0.3"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
web-sys = { version = "0.3", features = ["Window", "Storage"] }
|
||||||
12
frontend/Trunk.toml
Normal file
12
frontend/Trunk.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[[proxy]]
|
||||||
|
rewrite = "/api/"
|
||||||
|
backend = "http://localhost:3000/api/"
|
||||||
|
|
||||||
|
[[hooks]]
|
||||||
|
stage = "build"
|
||||||
|
command = "sh"
|
||||||
|
command_arguments = ["-c", "npx @tailwindcss/cli -i input.css -o public/tailwind.css"]
|
||||||
|
|
||||||
|
[build]
|
||||||
|
target = "index.html"
|
||||||
|
dist = "dist"
|
||||||
BIN
frontend/icon-192.png
Normal file
BIN
frontend/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 623 KiB |
BIN
frontend/icon-512.png
Normal file
BIN
frontend/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 623 KiB |
57
frontend/index.html
Normal file
57
frontend/index.html
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||||
|
<title>VibeTorrent</title>
|
||||||
|
|
||||||
|
<!-- PWA & Mobile Capable -->
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="theme-color" content="#111827" />
|
||||||
|
<link rel="manifest" href="manifest.json" />
|
||||||
|
<link rel="apple-touch-icon" href="icon-192.png" />
|
||||||
|
|
||||||
|
<!-- Trunk Assets -->
|
||||||
|
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="z" />
|
||||||
|
<link data-trunk rel="css" href="public/tailwind.css" />
|
||||||
|
<link data-trunk rel="copy-file" href="manifest.json" />
|
||||||
|
<link data-trunk rel="copy-file" href="icon-192.png" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var t = localStorage.getItem("vibetorrent_theme");
|
||||||
|
var c = "#0f172a"; // Midnight (default)
|
||||||
|
var tc = "#94a3b8"; // Text Color (Slate 400)
|
||||||
|
|
||||||
|
if (t === "Light") {
|
||||||
|
c = "#f9fafb"; // Gray 50
|
||||||
|
tc = "#111827"; // Gray 900
|
||||||
|
} else if (t === "Amoled") {
|
||||||
|
c = "#000000";
|
||||||
|
tc = "#e5e7eb"; // Gray 200
|
||||||
|
}
|
||||||
|
|
||||||
|
var s = document.createElement("style");
|
||||||
|
s.innerHTML = "body { background-color: " + c + "; color: " + tc + "; }";
|
||||||
|
document.head.appendChild(s);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app-loading" style="display: flex; justify-content: center; align-items: center; height: 100vh;">
|
||||||
|
<div
|
||||||
|
style="width: 40px; height: 40px; border: 3px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; opacity: 0.5;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
7
frontend/input.css
Normal file
7
frontend/input.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-gray-900: #111827;
|
||||||
|
--color-gray-800: #1f2937;
|
||||||
|
--color-gray-700: #374151;
|
||||||
|
}
|
||||||
20
frontend/manifest.json
Normal file
20
frontend/manifest.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "VibeTorrent",
|
||||||
|
"short_name": "VibeTorrent",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#111827",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1225
frontend/package-lock.json
generated
Normal file
1225
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
frontend/package.json
Normal file
20
frontend/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "tailwind.config.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.18"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/cli": "^4.1.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
1606
frontend/public/tailwind.css
Normal file
1606
frontend/public/tailwind.css
Normal file
File diff suppressed because it is too large
Load Diff
609
frontend/src/app.rs
Normal file
609
frontend/src/app.rs
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
use leptos::*;
|
||||||
|
use crate::models::{Torrent, AppEvent, TorrentStatus, Theme};
|
||||||
|
use crate::components::context_menu::ContextMenu;
|
||||||
|
use gloo_net::eventsource::futures::EventSource;
|
||||||
|
use futures::StreamExt;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn App() -> impl IntoView {
|
||||||
|
// Signals
|
||||||
|
let (torrents, set_torrents) = create_signal(Vec::<Torrent>::new());
|
||||||
|
let (sort_key, set_sort_key) = create_signal(6); // 6=Added Date
|
||||||
|
let (sort_asc, set_sort_asc) = create_signal(false); // Descending (Newest first)
|
||||||
|
let (filter_status, set_filter_status) = create_signal(Option::<TorrentStatus>::None);
|
||||||
|
let (active_tab, set_active_tab) = create_signal("torrents");
|
||||||
|
let (show_mobile_sidebar, set_show_mobile_sidebar) = create_signal(false);
|
||||||
|
// Theme with Persistence
|
||||||
|
let (theme, set_theme) = create_signal({
|
||||||
|
let storage = window().local_storage().ok().flatten();
|
||||||
|
let saved = storage.and_then(|s| s.get_item("vibetorrent_theme").ok().flatten());
|
||||||
|
match saved.as_deref() {
|
||||||
|
Some("Light") => Theme::Light,
|
||||||
|
Some("Amoled") => Theme::Amoled,
|
||||||
|
_ => Theme::Midnight,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist Theme
|
||||||
|
create_effect(move |_| {
|
||||||
|
let val = match theme.get() {
|
||||||
|
Theme::Midnight => "Midnight",
|
||||||
|
Theme::Light => "Light",
|
||||||
|
Theme::Amoled => "Amoled",
|
||||||
|
};
|
||||||
|
if let Some(storage) = window().local_storage().ok().flatten() {
|
||||||
|
let _ = storage.set_item("vibetorrent_theme", val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove Loading Spinner (Fix for spinner hanging)
|
||||||
|
create_effect(move |_| {
|
||||||
|
if let Some(doc) = window().document() {
|
||||||
|
if let Some(el) = doc.get_element_by_id("app-loading") {
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Context Menu Signals
|
||||||
|
let (cm_visible, set_cm_visible) = create_signal(false);
|
||||||
|
let (cm_pos, set_cm_pos) = create_signal((0, 0));
|
||||||
|
let (cm_target_hash, set_cm_target_hash) = create_signal(String::new());
|
||||||
|
|
||||||
|
// Debug: Last Updated Timestamp
|
||||||
|
let (last_updated, set_last_updated) = create_signal(0u64);
|
||||||
|
|
||||||
|
// Derived: Filtered & Sorted Logic
|
||||||
|
let processed_torrents = create_memo(move |_| {
|
||||||
|
let mut items = torrents.get();
|
||||||
|
if let Some(status) = filter_status.get() {
|
||||||
|
items.retain(|t| t.status == status);
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = sort_key.get();
|
||||||
|
let asc = sort_asc.get();
|
||||||
|
|
||||||
|
items.sort_by(|a, b| {
|
||||||
|
let cmp = match key {
|
||||||
|
0 => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||||
|
1 => a.size.cmp(&b.size),
|
||||||
|
2 => a.percent_complete.partial_cmp(&b.percent_complete).unwrap_or(std::cmp::Ordering::Equal),
|
||||||
|
3 => a.down_rate.cmp(&b.down_rate),
|
||||||
|
4 => a.up_rate.cmp(&b.up_rate),
|
||||||
|
5 => a.eta.cmp(&b.eta),
|
||||||
|
6 => a.added_date.cmp(&b.added_date),
|
||||||
|
_ => std::cmp::Ordering::Equal,
|
||||||
|
};
|
||||||
|
if asc { cmp } else { cmp.reverse() }
|
||||||
|
});
|
||||||
|
items
|
||||||
|
});
|
||||||
|
|
||||||
|
let sort = move |key: i32| {
|
||||||
|
if sort_key.get() == key {
|
||||||
|
set_sort_asc.update(|a| *a = !*a);
|
||||||
|
} else {
|
||||||
|
set_sort_key.set(key);
|
||||||
|
set_sort_asc.set(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add Torrent Logic
|
||||||
|
let (show_modal, set_show_modal) = create_signal(false);
|
||||||
|
let (magnet_link, set_magnet_link) = create_signal(String::new());
|
||||||
|
|
||||||
|
let add_torrent = move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
let uri = magnet_link.get();
|
||||||
|
if uri.is_empty() { return; }
|
||||||
|
let client = gloo_net::http::Request::post("/api/torrents/add")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(serde_json::to_string(&serde_json::json!({ "uri": uri })).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
if client.send().await.is_ok() {
|
||||||
|
set_magnet_link.set(String::new());
|
||||||
|
set_show_modal.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connect SSE
|
||||||
|
create_effect(move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
logging::log!("Connecting to SSE...");
|
||||||
|
let mut es = EventSource::new("/api/events").unwrap();
|
||||||
|
let mut stream = es.subscribe("message").unwrap();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match stream.next().await {
|
||||||
|
Some(Ok((_, msg))) => {
|
||||||
|
let data = msg.data().as_string().unwrap();
|
||||||
|
match serde_json::from_str::<AppEvent>(&data) {
|
||||||
|
Ok(event) => {
|
||||||
|
if let AppEvent::FullList(list, ts) = event {
|
||||||
|
set_torrents.set(list);
|
||||||
|
set_last_updated.set(ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging::error!("Failed to parse SSE JSON: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Err(e)) => {
|
||||||
|
logging::error!("SSE Stream Error: {:?}", e);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
logging::warn!("SSE Stream Ended (None received)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logging::warn!("SSE Task Exiting");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formatting Helpers
|
||||||
|
let format_bytes = |bytes: i64| {
|
||||||
|
if bytes < 1024 { format!("{} B", bytes) }
|
||||||
|
else if bytes < 1048576 { format!("{:.1} KB", bytes as f64 / 1024.0) }
|
||||||
|
else if bytes < 1073741824 { format!("{:.1} MB", bytes as f64 / 1048576.0) }
|
||||||
|
else { format!("{:.1} GB", bytes as f64 / 1073741824.0) }
|
||||||
|
};
|
||||||
|
|
||||||
|
let format_eta = |eta: i64| {
|
||||||
|
if eta <= 0 || eta > 31536000 { return "∞".to_string(); }
|
||||||
|
let h = eta / 3600;
|
||||||
|
let m = (eta % 3600) / 60;
|
||||||
|
format!("{}h {}m", h, m)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Theme Engine
|
||||||
|
let get_theme_classes = move || {
|
||||||
|
match theme.get() {
|
||||||
|
Theme::Midnight => (
|
||||||
|
"bg-[#0a0a0c] text-white selection:bg-blue-500/30", // Main bg
|
||||||
|
"bg-[#111116]/80 backdrop-blur-xl border-white/5", // Sidebar
|
||||||
|
"bg-[#111116] border-white/5 shadow-2xl", // Card/Table bg
|
||||||
|
"text-gray-200", // Primary Text
|
||||||
|
"text-gray-400", // Secondary Text
|
||||||
|
"hover:bg-white/5", // Hover
|
||||||
|
"border-white/5" // Border
|
||||||
|
),
|
||||||
|
Theme::Light => (
|
||||||
|
"bg-gray-50 text-gray-900 selection:bg-blue-500/20",
|
||||||
|
"bg-white/80 backdrop-blur-xl border-gray-200",
|
||||||
|
"bg-white border-gray-200 shadow-xl",
|
||||||
|
"text-gray-900",
|
||||||
|
"text-gray-500",
|
||||||
|
"hover:bg-gray-100",
|
||||||
|
"border-gray-200"
|
||||||
|
),
|
||||||
|
Theme::Amoled => (
|
||||||
|
"bg-black text-white selection:bg-blue-600/40",
|
||||||
|
"bg-black border-gray-800",
|
||||||
|
"bg-black border-gray-800",
|
||||||
|
"text-gray-200",
|
||||||
|
"text-gray-500",
|
||||||
|
"hover:bg-gray-900",
|
||||||
|
"border-gray-800"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let filter_btn_class = move |status: Option<TorrentStatus>| {
|
||||||
|
let (base_bg, _, _, _, text_sec, hover, _) = get_theme_classes();
|
||||||
|
let base = "block px-4 py-2 rounded-xl transition-all duration-200 text-left w-full flex items-center gap-3 border";
|
||||||
|
let active = filter_status.get() == status;
|
||||||
|
if active {
|
||||||
|
format!("{} bg-blue-600/20 text-blue-500 border-blue-500/30 font-medium", base)
|
||||||
|
} else {
|
||||||
|
format!("{} {} {} border-transparent hover:text-gray-300", base, hover, text_sec)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tab_btn_class = move |tab: &str| {
|
||||||
|
let active = active_tab.get() == tab;
|
||||||
|
let base = "flex flex-col items-center justify-center p-2 flex-1 transition-colors relative";
|
||||||
|
if active {
|
||||||
|
format!("{} text-blue-500", base)
|
||||||
|
} else {
|
||||||
|
"flex flex-col items-center justify-center p-2 flex-1 transition-colors relative text-gray-400 hover:text-gray-300".to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sidebar Content Logic
|
||||||
|
let sidebar_content = move || {
|
||||||
|
let (_, _, _, text_pri, text_sec, _, border) = get_theme_classes();
|
||||||
|
view! {
|
||||||
|
<div class="mb-10 px-2 flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
||||||
|
<svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 tracking-tight">
|
||||||
|
"VibeTorrent"
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={format!("text-xs font-bold uppercase tracking-widest mb-4 px-2 {}", text_sec)}>"Filters"</div>
|
||||||
|
<nav class="space-y-2 flex-1">
|
||||||
|
<button class={move || filter_btn_class(None)} on:click=move |_| { set_filter_status.set(None); set_show_mobile_sidebar.set(false); }>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-400"></span>
|
||||||
|
"All Torrents"
|
||||||
|
</button>
|
||||||
|
<button class={move || filter_btn_class(Some(TorrentStatus::Downloading))} on:click=move |_| { set_filter_status.set(Some(TorrentStatus::Downloading)); set_show_mobile_sidebar.set(false); }>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
|
||||||
|
"Downloading"
|
||||||
|
</button>
|
||||||
|
<button class={move || filter_btn_class(Some(TorrentStatus::Seeding))} on:click=move |_| { set_filter_status.set(Some(TorrentStatus::Seeding)); set_show_mobile_sidebar.set(false); }>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-green-500"></span>
|
||||||
|
"Seeding"
|
||||||
|
</button>
|
||||||
|
<button class={move || filter_btn_class(Some(TorrentStatus::Paused))} on:click=move |_| { set_filter_status.set(Some(TorrentStatus::Paused)); set_show_mobile_sidebar.set(false); }>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-yellow-500"></span>
|
||||||
|
"Paused"
|
||||||
|
</button>
|
||||||
|
<button class={move || filter_btn_class(Some(TorrentStatus::Error))} on:click=move |_| { set_filter_status.set(Some(TorrentStatus::Error)); set_show_mobile_sidebar.set(false); }>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-red-500"></span>
|
||||||
|
"Errors"
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class={format!("mt-auto pt-6 border-t {}", border)}>
|
||||||
|
<div class={format!("rounded-xl p-4 border relative overflow-hidden {}", border)}>
|
||||||
|
<div class={format!("absolute inset-0 opacity-5 {}", if theme.get() == Theme::Light { "bg-black" } else { "bg-white" })}></div>
|
||||||
|
<div class={format!("text-xs mb-2 z-10 relative {}", text_sec)}>"Storage"</div>
|
||||||
|
<div class="w-full bg-gray-500/20 rounded-full h-1.5 mb-2 overflow-hidden z-10 relative">
|
||||||
|
<div class="bg-gradient-to-r from-blue-500 to-purple-500 w-[70%] h-full rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<div class={format!("flex justify-between text-xs z-10 relative {}", text_sec)}>
|
||||||
|
<span>"700 GB used"</span>
|
||||||
|
<span>"1 TB total"</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let theme_option = move |t: Theme, label: &str, color: &str| {
|
||||||
|
let is_active = theme.get() == t;
|
||||||
|
let border_class = if is_active { "border-blue-500 ring-1 ring-blue-500/50" } else { "border-transparent hover:border-gray-500/30" };
|
||||||
|
let label_owned = label.to_string();
|
||||||
|
let color_owned = color.to_string();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
class={format!("flex items-center gap-4 p-4 rounded-xl border bg-black/5 dark:bg-white/5 transition-all w-full text-left {}", border_class)}
|
||||||
|
on:click=move |_| set_theme.set(t.clone())
|
||||||
|
>
|
||||||
|
<div class={format!("w-12 h-12 rounded-full shadow-lg flex-shrink-0 {}", color_owned)}></div>
|
||||||
|
<div>
|
||||||
|
<div class="font-bold">{label_owned}</div>
|
||||||
|
<div class="text-xs opacity-60">"Select this theme"</div>
|
||||||
|
</div>
|
||||||
|
{if is_active {
|
||||||
|
view! {
|
||||||
|
<div class="ml-auto text-blue-500">
|
||||||
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
|
||||||
|
</div>
|
||||||
|
}.into_view()
|
||||||
|
} else {
|
||||||
|
view! {}.into_view()
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
{move || {
|
||||||
|
let (main_bg, sidebar_bg, card_bg, text_pri, text_sec, hover, border) = get_theme_classes();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class={format!("min-h-screen font-sans flex flex-col md:flex-row overflow-hidden transition-colors duration-300 {}", main_bg)}>
|
||||||
|
// DESKTOP SIDEBAR
|
||||||
|
<aside class={format!("hidden md:flex flex-col w-72 border-r p-6 z-20 h-screen {}", sidebar_bg)}>
|
||||||
|
{sidebar_content}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
// MOBILE SIDEBAR
|
||||||
|
<div class={move || if show_mobile_sidebar.get() { "fixed inset-0 z-50 flex md:hidden" } else { "hidden" }}>
|
||||||
|
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity" on:click=move |_| set_show_mobile_sidebar.set(false)></div>
|
||||||
|
<aside class={format!("relative w-80 max-w-[85vw] h-full shadow-2xl p-6 flex flex-col animate-in slide-in-from-left duration-300 border-r {}", sidebar_bg)}>
|
||||||
|
<button class={format!("absolute top-4 right-4 p-2 hover:opacity-80 {}", text_sec)} on:click=move |_| set_show_mobile_sidebar.set(false)>
|
||||||
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
|
</button>
|
||||||
|
{sidebar_content}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// MAIN CONTENT
|
||||||
|
<main class="flex-1 h-screen overflow-y-auto overflow-x-hidden relative pb-24 md:pb-0">
|
||||||
|
<header class={format!("sticky top-0 z-10 border-b px-6 py-4 flex justify-between items-center {}", sidebar_bg)}>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button class={format!("md:hidden p-1 -ml-2 hover:opacity-80 {}", text_sec)} on:click=move |_| set_show_mobile_sidebar.set(true)>
|
||||||
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||||
|
</button>
|
||||||
|
<h2 class={format!("text-xl font-bold flex items-center gap-2 {}", text_pri)}>
|
||||||
|
{move || if active_tab.get() == "settings" { "Settings" } else if active_tab.get() == "dashboard" { "Dashboard" } else {
|
||||||
|
match filter_status.get() {
|
||||||
|
None => "All Torrents",
|
||||||
|
Some(TorrentStatus::Downloading) => "Downloading",
|
||||||
|
Some(TorrentStatus::Seeding) => "Seeding",
|
||||||
|
Some(TorrentStatus::Paused) => "Paused",
|
||||||
|
Some(TorrentStatus::Error) => "Errors",
|
||||||
|
_ => "Torrents"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class={format!("hidden md:block text-xs font-mono {}", text_sec)}>
|
||||||
|
"Server Time: "
|
||||||
|
{move || {
|
||||||
|
let ts = last_updated.get();
|
||||||
|
if ts == 0 {
|
||||||
|
"Waiting...".to_string()
|
||||||
|
} else {
|
||||||
|
let s = ts % 60;
|
||||||
|
let m = (ts / 60) % 60;
|
||||||
|
let h = (ts / 3600) % 24;
|
||||||
|
format!("{:02}:{:02}:{:02} UTC", h, m, s)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="px-5 py-2.5 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-xl hover:shadow-lg hover:shadow-blue-500/30 hover:scale-105 active:scale-95 transition-all text-sm font-bold text-white flex items-center gap-2"
|
||||||
|
on:click=move |_| set_show_modal.set(true)
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
|
||||||
|
<span class="hidden md:inline">"Add Torrent"</span>
|
||||||
|
<span class="md:hidden">"Add"</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class={format!("hidden md:flex p-2.5 rounded-xl hover:bg-white/5 active:scale-95 transition-all text-gray-400 hover:text-white border border-transparent hover:border-white/10 {}", if active_tab.get() == "settings" { "bg-blue-500/10 text-blue-500 border-blue-500/20" } else { "" })}
|
||||||
|
on:click=move |_| set_active_tab.set(if active_tab.get() == "settings" { "torrents" } else { "settings" })
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="p-6 max-w-7xl mx-auto animate-in fade-in duration-500">
|
||||||
|
{move || if active_tab.get() == "settings" {
|
||||||
|
view! {
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h3 class={format!("text-lg font-bold mb-4 {}", text_pri)}>"Appearance"</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
{theme_option(Theme::Midnight, "Midnight", "bg-[#0a0a0c] border border-gray-700")}
|
||||||
|
{theme_option(Theme::Light, "Light", "bg-gray-100 border border-gray-300")}
|
||||||
|
{theme_option(Theme::Amoled, "Amoled", "bg-black border border-gray-800")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class={format!("p-6 rounded-2xl border {}", card_bg)}>
|
||||||
|
<h3 class={format!("text-lg font-bold mb-2 {}", text_pri)}>"About VibeTorrent"</h3>
|
||||||
|
<p class={format!("text-sm {}", text_sec)}>"Version 3.0.0 (Rust + WebAssembly)"</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_view()
|
||||||
|
} else if active_tab.get() == "dashboard" {
|
||||||
|
view! {
|
||||||
|
<div class="text-center py-20 opacity-50">"Dashboard Charts Coming Soon..."</div>
|
||||||
|
}.into_view()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
// Torrent List (Desktop)
|
||||||
|
<div class={format!("hidden md:block rounded-2xl border shadow-sm overflow-hidden {}", card_bg)}>
|
||||||
|
<table class="w-full text-left table-fixed">
|
||||||
|
<thead class={format!("uppercase text-xs font-bold tracking-wider {}", text_sec)}>
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-4 cursor-pointer hover:opacity-80" on:click=move |_| sort(0)>"Name"</th>
|
||||||
|
<th class="px-6 py-4 cursor-pointer hover:opacity-80 w-28 text-right whitespace-nowrap" on:click=move |_| sort(1)>"Size"</th>
|
||||||
|
<th class="px-6 py-4 cursor-pointer hover:opacity-80 w-36" on:click=move |_| sort(2)>"Progress"</th>
|
||||||
|
<th class="px-6 py-4 cursor-pointer hover:opacity-80 w-28 text-right whitespace-nowrap" on:click=move |_| sort(3)>"Down"</th>
|
||||||
|
<th class="px-6 py-4 cursor-pointer hover:opacity-80 w-28 text-right whitespace-nowrap" on:click=move |_| sort(4)>"Up"</th>
|
||||||
|
<th class="px-6 py-4 cursor-pointer hover:opacity-80 w-28 text-right whitespace-nowrap" on:click=move |_| sort(5)>"ETA"</th>
|
||||||
|
<th class="px-6 py-4 text-center w-28">"Status"</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class={format!("divide-y {}", border)}>
|
||||||
|
<For
|
||||||
|
each=move || processed_torrents.get()
|
||||||
|
key=|t| format!("{}-{}-{}-{}-{}-{}", t.hash, t.down_rate, t.up_rate, t.percent_complete, t.eta, t.error_message)
|
||||||
|
children=move |torrent| {
|
||||||
|
let status_color = match torrent.status {
|
||||||
|
TorrentStatus::Downloading => "text-blue-500 bg-blue-500/10 border-blue-500/20",
|
||||||
|
TorrentStatus::Seeding => "text-green-500 bg-green-500/10 border-green-500/20",
|
||||||
|
TorrentStatus::Paused => "text-yellow-500 bg-yellow-500/10 border-yellow-500/20",
|
||||||
|
TorrentStatus::Error => "text-red-500 bg-red-500/10 border-red-500/20",
|
||||||
|
_ => "text-gray-400 bg-gray-500/10"
|
||||||
|
};
|
||||||
|
let status_text = format!("{:?}", torrent.status);
|
||||||
|
let error_msg = torrent.error_message.clone();
|
||||||
|
let error_msg_view = error_msg.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<tr
|
||||||
|
class={format!("transition-colors group {}", hover)}
|
||||||
|
on:contextmenu=move |e: web_sys::MouseEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
set_cm_pos.set((e.client_x(), e.client_y()));
|
||||||
|
set_cm_target_hash.set(torrent.hash.clone());
|
||||||
|
set_cm_visible.set(true);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4 max-w-sm">
|
||||||
|
<div class={format!("font-medium truncate transition-colors {}", text_pri)} title={torrent.name.clone()}>
|
||||||
|
{torrent.name}
|
||||||
|
</div>
|
||||||
|
<Show when=move || !error_msg.is_empty() fallback=|| ()>
|
||||||
|
<div class="text-xs text-red-500 mt-1">{error_msg_view.clone()}</div>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<td class={format!("px-6 py-4 text-sm font-mono text-right whitespace-nowrap {}", text_sec)}>{format_bytes(torrent.size)}</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<div class={format!("flex justify-between text-xs {}", text_sec)}>
|
||||||
|
<span>{format!("{:.1}%", torrent.percent_complete)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-500/20 rounded-full h-1.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="bg-blue-500 h-full rounded-full transition-all duration-500"
|
||||||
|
style=format!("width: {}%", torrent.percent_complete)
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class={format!("px-6 py-4 font-mono text-xs text-right whitespace-nowrap {}", text_sec)}>
|
||||||
|
{if torrent.down_rate > 0 {
|
||||||
|
view! { <span class="text-blue-500">{format_bytes(torrent.down_rate)} "/s"</span> }.into_view()
|
||||||
|
} else {
|
||||||
|
view! { <span class="text-gray-600">"-"</span> }.into_view()
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td class={format!("px-6 py-4 font-mono text-xs text-right whitespace-nowrap {}", text_sec)}>
|
||||||
|
{if torrent.up_rate > 0 {
|
||||||
|
view! { <span class="text-green-500">{format_bytes(torrent.up_rate)} "/s"</span> }.into_view()
|
||||||
|
} else {
|
||||||
|
view! { <span class="text-gray-600">"-"</span> }.into_view()
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td class={format!("px-6 py-4 text-xs font-mono text-right whitespace-nowrap {}", text_sec)}>
|
||||||
|
{format_eta(torrent.eta)}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-center">
|
||||||
|
<span class={format!("text-[10px] font-bold px-2.5 py-1 rounded-full border {}", status_color)}>
|
||||||
|
{status_text}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Torrent List (Mobile)
|
||||||
|
<div class="md:hidden space-y-4">
|
||||||
|
<For
|
||||||
|
each=move || processed_torrents.get()
|
||||||
|
key=|t| format!("{}-{}-{}-{}-{}-{}", t.hash, t.down_rate, t.up_rate, t.percent_complete, t.eta, t.error_message)
|
||||||
|
children=move |torrent| {
|
||||||
|
let status_color = match torrent.status {
|
||||||
|
TorrentStatus::Downloading => "text-blue-500",
|
||||||
|
TorrentStatus::Seeding => "text-green-500",
|
||||||
|
TorrentStatus::Paused => "text-yellow-500",
|
||||||
|
TorrentStatus::Error => "text-red-500",
|
||||||
|
_ => "text-gray-400"
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class={format!("rounded-2xl p-4 border shadow-sm active:scale-[0.98] transition-transform {}", card_bg)}>
|
||||||
|
<div class="flex justify-between items-start mb-3">
|
||||||
|
<div class={format!("font-medium line-clamp-2 pr-4 {}", text_pri)}>{torrent.name}</div>
|
||||||
|
<div class={format!("text-xs font-bold {}", status_color)}>
|
||||||
|
{format!("{:?}", torrent.status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class={format!("flex justify-between text-xs mb-1 {}", text_sec)}>
|
||||||
|
<span>{format_bytes(torrent.size)}</span>
|
||||||
|
<span>{format!("{:.1}%", torrent.percent_complete)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-500/20 rounded-full h-1.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="bg-blue-500 h-full rounded-full transition-all duration-500"
|
||||||
|
style=format!("width: {}%", torrent.percent_complete)
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center text-xs font-mono opacity-80">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<span class="text-blue-500">"↓ " {format_bytes(torrent.down_rate)} "/s"</span>
|
||||||
|
<span class="text-green-500">"↑ " {format_bytes(torrent.up_rate)} "/s"</span>
|
||||||
|
</div>
|
||||||
|
<div class={text_sec}>{format_eta(torrent.eta)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Show when=move || processed_torrents.get().is_empty() fallback=|| ()>
|
||||||
|
<div class={format!("p-12 text-center mt-10 {}", text_sec)}>
|
||||||
|
<div class="mb-4 text-6xl opacity-20">"📭"</div>
|
||||||
|
"No torrents found."
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}.into_view()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
// MOBILE BOTTOM NAV
|
||||||
|
<nav class={format!("md:hidden fixed bottom-0 inset-x-0 backdrop-blur-xl border-t pb-safe z-30 flex justify-between items-center px-6 py-2 {}", sidebar_bg)}>
|
||||||
|
<button class={move || tab_btn_class("torrents")} on:click=move |_| set_active_tab.set("torrents")>
|
||||||
|
<svg class="w-6 h-6 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||||
|
<span class="text-[10px] font-medium">"List"</span>
|
||||||
|
</button>
|
||||||
|
<button class={move || tab_btn_class("dashboard")} on:click=move |_| set_active_tab.set("dashboard")>
|
||||||
|
<svg class="w-6 h-6 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||||
|
<span class="text-[10px] font-medium">"Dashboard"</span>
|
||||||
|
</button>
|
||||||
|
<button class={move || tab_btn_class("settings")} on:click=move |_| set_active_tab.set("settings")>
|
||||||
|
<svg class="w-6 h-6 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /></svg>
|
||||||
|
<span class="text-[10px] font-medium">"Settings"</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
// Modal (Dark backdrop always)
|
||||||
|
<Show when=move || show_modal.get() fallback=|| ()>
|
||||||
|
<div class="fixed inset-0 bg-black/80 backdrop-blur-md flex items-end md:items-center justify-center z-50 animate-in fade-in duration-200 sm:p-4">
|
||||||
|
<div class="bg-[#16161c] p-6 rounded-t-2xl md:rounded-2xl w-full max-w-lg shadow-2xl border border-white/10 ring-1 ring-white/5 transform transition-all animate-in slide-in-from-bottom-10 md:slide-in-from-bottom-0 md:zoom-in-95">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h3 class="text-xl font-bold text-white">"Add New Torrent"</h3>
|
||||||
|
<button on:click=move |_| set_show_modal.set(false) class="p-1 hover:bg-white/10 rounded-full transition-colors">
|
||||||
|
<svg class="w-6 h-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="relative mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="w-full bg-black/30 border border-white/10 rounded-xl p-4 pl-12 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-all placeholder:text-gray-600"
|
||||||
|
placeholder="Paste Magnet Link or URL"
|
||||||
|
on:input=move |ev| set_magnet_link.set(event_target_value(&ev))
|
||||||
|
prop:value=magnet_link
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">
|
||||||
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button class="flex-1 px-4 py-3.5 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-xl hover:shadow-[0_0_20px_rgba(59,130,246,0.3)] transition-all font-bold text-white shadow-lg active:scale-95" on:click=add_torrent>
|
||||||
|
"Add Download"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<ContextMenu
|
||||||
|
position=cm_pos.get()
|
||||||
|
visible=cm_visible.get()
|
||||||
|
torrent_hash=cm_target_hash.get()
|
||||||
|
on_close=Callback::from(move |_| set_cm_visible.set(false))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
frontend/src/components/context_menu.rs
Normal file
99
frontend/src/components/context_menu.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
use leptos::*;
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
use crate::models::Torrent;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ContextMenu(
|
||||||
|
position: (i32, i32),
|
||||||
|
visible: bool,
|
||||||
|
torrent_hash: String,
|
||||||
|
on_close: Callback<()>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let handle_action = move |action: &str| {
|
||||||
|
let hash = torrent_hash.clone();
|
||||||
|
let action_str = action.to_string();
|
||||||
|
let close = on_close.clone();
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"hash": hash,
|
||||||
|
"action": action_str
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = Request::post("/api/torrents/action")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(body.to_string())
|
||||||
|
.unwrap() // Unwrap the Result<RequestBuilder, JsValue>
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
close.call(());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if !visible {
|
||||||
|
return view! {}.into_view();
|
||||||
|
}
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[100]"
|
||||||
|
on:click=move |_| on_close.call(())
|
||||||
|
on:contextmenu=move |e| e.prevent_default()
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute bg-[#111116]/95 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl py-2 min-w-[200px] animate-in fade-in zoom-in-95 duration-100"
|
||||||
|
style=format!("left: {}px; top: {}px", position.0, position.1)
|
||||||
|
on:click=move |e| e.stop_propagation()
|
||||||
|
>
|
||||||
|
<div class="px-3 py-1 text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">"Actions"</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full text-left px-4 py-2.5 hover:bg-white/10 flex items-center gap-3 transition-colors text-white"
|
||||||
|
on:click={
|
||||||
|
let handle_action = handle_action.clone();
|
||||||
|
move |_| handle_action("start")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
|
"Resume"
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full text-left px-4 py-2.5 hover:bg-white/10 flex items-center gap-3 transition-colors text-white"
|
||||||
|
on:click={
|
||||||
|
let handle_action = handle_action.clone();
|
||||||
|
move |_| handle_action("stop")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
|
"Pause"
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="h-px bg-white/10 my-1"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full text-left px-4 py-2.5 hover:bg-red-500/20 text-red-500 hover:text-red-400 flex items-center gap-3 transition-colors"
|
||||||
|
on:click={
|
||||||
|
let handle_action = handle_action.clone();
|
||||||
|
move |_| handle_action("delete")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||||
|
"Delete"
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full text-left px-4 py-2.5 hover:bg-red-900/20 text-red-600 hover:text-red-400 flex items-center gap-3 transition-colors text-xs"
|
||||||
|
on:click={
|
||||||
|
let handle_action = handle_action.clone();
|
||||||
|
move |_| handle_action("delete_with_data")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||||
|
<span>"Delete with Data"</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_view()
|
||||||
|
}
|
||||||
1
frontend/src/components/mod.rs
Normal file
1
frontend/src/components/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod context_menu;
|
||||||
16
frontend/src/lib.rs
Normal file
16
frontend/src/lib.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
mod app;
|
||||||
|
mod models;
|
||||||
|
mod components;
|
||||||
|
use components::context_menu::ContextMenu;
|
||||||
|
|
||||||
|
use leptos::*;
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use app::App;
|
||||||
|
|
||||||
|
#[wasm_bindgen(start)]
|
||||||
|
pub fn main() {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
console_log::init_with_level(log::Level::Debug).unwrap();
|
||||||
|
|
||||||
|
mount_to_body(|| view! { <App/> })
|
||||||
|
}
|
||||||
48
frontend/src/models.rs
Normal file
48
frontend/src/models.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
|
pub struct Torrent {
|
||||||
|
pub hash: String,
|
||||||
|
pub name: String,
|
||||||
|
pub size: i64,
|
||||||
|
pub completed: i64,
|
||||||
|
pub down_rate: i64,
|
||||||
|
pub up_rate: i64,
|
||||||
|
pub eta: i64,
|
||||||
|
pub percent_complete: f64,
|
||||||
|
pub status: TorrentStatus,
|
||||||
|
pub error_message: String,
|
||||||
|
pub added_date: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TorrentStatus {
|
||||||
|
Downloading,
|
||||||
|
Seeding,
|
||||||
|
Paused,
|
||||||
|
Error,
|
||||||
|
Checking,
|
||||||
|
Queued,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(tag = "type", content = "data")]
|
||||||
|
pub enum AppEvent {
|
||||||
|
FullList(Vec<Torrent>, u64),
|
||||||
|
Update(TorrentUpdate),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct TorrentUpdate {
|
||||||
|
pub hash: String,
|
||||||
|
pub down_rate: Option<i64>,
|
||||||
|
pub up_rate: Option<i64>,
|
||||||
|
pub percent_complete: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Theme {
|
||||||
|
Midnight,
|
||||||
|
Light,
|
||||||
|
Amoled,
|
||||||
|
}
|
||||||
16
frontend/tailwind.config.js
Normal file
16
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./index.html", "./src/**/*.{rs,html}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
gray: {
|
||||||
|
900: '#111827',
|
||||||
|
800: '#1f2937',
|
||||||
|
700: '#374151',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user