feat: Initial release with MIPS support

This commit is contained in:
spinline
2026-01-29 23:17:19 +03:00
commit 5052a1787a
26 changed files with 7428 additions and 0 deletions

45
.github/workflows/build-mips.yml vendored Normal file
View 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
View File

@@ -0,0 +1,7 @@
/target
**/.DS_Store
*.log
result.xml
**/node_modules
frontend/dist
backend.log

2872
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

3
Cargo.toml Normal file
View File

@@ -0,0 +1,3 @@
[workspace]
members = ["backend", "frontend"]
resolver = "2"

23
backend/Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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", &params).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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

BIN
frontend/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

57
frontend/index.html Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

20
frontend/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

609
frontend/src/app.rs Normal file
View 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>
}
}}
}
}

View 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()
}

View File

@@ -0,0 +1 @@
pub mod context_menu;

16
frontend/src/lib.rs Normal file
View 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
View 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,
}

View 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: [],
}