Compare commits

..

15 Commits

Author SHA1 Message Date
spinline
fddc81365b feat: complete modernization with shadcn, stateless auth, and performance optimizations
All checks were successful
Build MIPS Binary / build (push) Successful in 5m20s
2026-02-10 22:16:36 +03:00
spinline
8815727620 feat: migrate to stateless server functions for auth with jwt and shadcn ui
Some checks failed
Build MIPS Binary / build (push) Failing after 3s
2026-02-10 19:20:36 +03:00
spinline
c85c75659e feat: modernize stack with shadcn, struct_patch and msgpack
Some checks failed
Build MIPS Binary / build (push) Failing after 6s
2026-02-10 19:02:53 +03:00
spinline
4b3e713657 refactor: move DB to shared crate, convert push endpoints to server functions, remove dead REST handlers
All checks were successful
Build MIPS Binary / build (push) Successful in 5m17s
2026-02-10 02:05:04 +03:00
spinline
c2bf6e6fd5 fix: remove unnecessary SSE user auth check - login already guards access
All checks were successful
Build MIPS Binary / build (push) Successful in 5m17s
2026-02-10 01:43:40 +03:00
spinline
94bc7cb91d fix: patch coarsetime with portable-atomic for MIPS AtomicU64 support
All checks were successful
Build MIPS Binary / build (push) Successful in 5m18s
2026-02-10 01:29:06 +03:00
spinline
5e1f4b18c2 refactor: migrate torrent/settings endpoints to Leptos Server Functions and remove third_party/coarsetime
Some checks failed
Build MIPS Binary / build (push) Failing after 4m15s
2026-02-10 00:27:39 +03:00
spinline
3f370389aa fix: SSE auth by capturing store context before async block and redirect to setup when needed 2026-02-10 00:12:08 +03:00
spinline
a4fe8d065c debug: log user value in SSE loop
All checks were successful
Build MIPS Binary / build (push) Successful in 5m21s
2026-02-09 23:55:57 +03:00
spinline
3215b38272 fix: add missing spawn_local import
All checks were successful
Build MIPS Binary / build (push) Successful in 5m20s
2026-02-09 23:48:52 +03:00
spinline
8eb594e804 refactor: move SSE logic to spawn_local with continuous user check
Some checks failed
Build MIPS Binary / build (push) Failing after 1m15s
2026-02-09 22:48:00 +03:00
spinline
518af10cd7 fix: use .0 for reading and .1 for writing signals
All checks were successful
Build MIPS Binary / build (push) Successful in 5m20s
2026-02-09 22:37:06 +03:00
spinline
0304c5cb7d fix: prevent panic by using signals for redirects and fix auth flow
Some checks failed
Build MIPS Binary / build (push) Failing after 1m15s
2026-02-09 22:32:19 +03:00
spinline
cee609700a fix: use navigate inside Router context and fix auth redirect flow
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-09 22:30:59 +03:00
spinline
a9de8aeb5a debug: add more SSE logging to trace message receiving
All checks were successful
Build MIPS Binary / build (push) Successful in 5m20s
2026-02-09 22:21:39 +03:00
54 changed files with 2418 additions and 4320 deletions

View File

@@ -26,7 +26,7 @@ jobs:
run: |
cd frontend
npm install
npx @tailwindcss/cli -i input.css -o public/tailwind.css
npx @tailwindcss/cli -i input.css -o public/tailwind.css --minify --content './src/**/*.rs'
# Trunk'ın optimizasyonunu kapalı (0) tutuyoruz çünkü Cargo.toml'daki opt-level='z' zaten o işi yapıyor.
trunk build --release

229
Cargo.lock generated
View File

@@ -320,17 +320,19 @@ dependencies = [
"dotenvy",
"futures",
"governor",
"jsonwebtoken",
"leptos",
"leptos_axum",
"mime_guess",
"openssl",
"quick-xml",
"rand 0.8.5",
"rmp-serde",
"rust-embed",
"serde",
"serde_json",
"shared",
"sqlx",
"struct-patch",
"thiserror 2.0.18",
"time",
"tokio",
@@ -1258,13 +1260,16 @@ dependencies = [
"gloo-timers",
"js-sys",
"leptos",
"leptos-shadcn-ui",
"leptos-use",
"leptos_router",
"log",
"rmp-serde",
"serde",
"serde-wasm-bindgen",
"serde_json",
"shared",
"struct-patch",
"tailwind_fuse",
"thiserror 2.0.18",
"uuid",
@@ -1398,8 +1403,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -2026,6 +2033,21 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"pem 3.0.6",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "jwt-simple"
version = "0.11.9"
@@ -2117,6 +2139,109 @@ dependencies = [
"web-sys",
]
[[package]]
name = "leptos-node-ref"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f57b1ebc451fe9e7b6c7eba680fa8bc7313b410cc6c0f18481cb55a60ff3ac6"
dependencies = [
"leptos",
"send_wrapper",
]
[[package]]
name = "leptos-shadcn-button"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6d1a7b813b726be7920f7238c127a14129ba4a45fa879312cad3ed2f8a1745"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-input"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0939cdad5a878d920decda39a4b42ecf4eba15736a92bbd73b1b408807899b8"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"regex",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-signal-management"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5097c5171eb0be12bbf8fd736f4e669012657112865506a825480f2b013f6de"
dependencies = [
"chrono",
"js-sys",
"leptos",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "leptos-shadcn-ui"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43430605d3d049a4cf68fb7dff4e6f940426ec48131f4662963f62f11baa3e18"
dependencies = [
"gloo-timers",
"leptos",
"leptos-node-ref",
"leptos-shadcn-button",
"leptos-shadcn-input",
"leptos-struct-component",
"leptos-style",
"leptos_router",
"tailwind_fuse",
]
[[package]]
name = "leptos-struct-component"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c32085b37b67e61e69e0949d94e36c40e4fde83867681cbb884f9cd40a43881e"
dependencies = [
"leptos",
"leptos-struct-component-macro",
]
[[package]]
name = "leptos-struct-component-macro"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a40efd792acc28a115605b84ecb39e89397a278950bc8f2aad1bdcc7af2033af"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "leptos-style"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c65408961a0bd8e70f317de8973d532a0cb9ffbac910c488d97f9c5a2e4411e2"
dependencies = [
"indexmap",
"leptos",
]
[[package]]
name = "leptos-use"
version = "0.16.3"
@@ -2557,6 +2682,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@@ -3237,6 +3372,39 @@ dependencies = [
"subtle",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]]
name = "rsa"
version = "0.7.2"
@@ -3651,12 +3819,20 @@ dependencies = [
name = "shared"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"bcrypt",
"bytes",
"cookie",
"jsonwebtoken",
"leptos",
"leptos_axum",
"leptos_router",
"quick-xml",
"rmp-serde",
"serde",
"sqlx",
"struct-patch",
"thiserror 2.0.18",
"tokio",
"utoipa",
@@ -3704,6 +3880,18 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simple_asn1"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -3997,6 +4185,26 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "struct-patch"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613c12642d0c0b051bb3faabfbabdb346497963acfe45622b72b4457d4c93a86"
dependencies = [
"struct-patch-derive",
]
[[package]]
name = "struct-patch-derive"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "716442fd9f9a6eb5f847b76cf6d09211f3bdf06f2e30c22e94e38d8ebafdd61a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4107,6 +4315,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca71fb01735fbc6fa13e9390d7a3037dde97053c0b65c0c72c0159cd009d26b"
dependencies = [
"nom",
"tailwind_fuse_macro",
]
[[package]]
name = "tailwind_fuse_macro"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efa51b9ff80b5533001f8452d254a688bc7bb39c6bb77f9e0a19c1664d035888"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
@@ -4640,6 +4861,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"

View File

@@ -17,4 +17,5 @@ strip = true
incremental = false
[patch.crates-io]
coarsetime = { path = "third_party/coarsetime" }
coarsetime = { path = "patches/coarsetime" }

View File

@@ -15,6 +15,8 @@ tower = { version = "0.5", features = ["util", "timeout"] }
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rmp-serde = "1.3"
struct-patch = "0.5"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio-stream = "0.1"
@@ -33,7 +35,6 @@ utoipa-swagger-ui = { version = "9.0", features = ["axum"], optional = true }
web-push = { version = "0.10", default-features = false, features = ["hyper-client"], optional = true }
base64 = "0.22"
openssl = { version = "0.10", features = ["vendored"], optional = true }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
bcrypt = "0.17.0"
axum-extra = { version = "0.10", features = ["cookie"] }
rand = "0.8"
@@ -44,4 +45,5 @@ governor = "0.10.4"
# Leptos
leptos = { version = "0.8.15", features = ["nightly"] }
leptos_axum = { version = "0.8.7" }
leptos_axum = { version = "0.8.7" }
jsonwebtoken = "9"

View File

@@ -9,106 +9,54 @@ pub enum DiffResult {
}
pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
// 1. Structural Check: Eğer torrent sayısı değişmişse (yeni eklenen veya silinen),
// şimdilik basitlik adına FullUpdate gönderiyoruz.
if old.len() != new.len() {
return DiffResult::FullUpdate;
}
// 2. Hash Set Karşılaştırması:
// Sıralama değişmiş olabilir ama torrentler aynı mı?
let old_map: HashMap<&str, &Torrent> = old.iter().map(|t| (t.hash.as_str(), t)).collect();
// Eğer yeni listedeki bir hash eski listede yoksa, yapı değişmiş demektir.
for new_t in new {
if !old_map.contains_key(new_t.hash.as_str()) {
return DiffResult::FullUpdate;
}
}
// 3. Alan Güncellemeleri (Partial Updates)
// Buraya geldiğimizde biliyoruz ki old ve new listelerindeki torrentler (hash olarak) aynı,
// sadece sıraları farklı olabilir veya içindeki veriler güncellenmiş olabilir.
let mut events = Vec::new();
for new_t in new {
// old_map'ten ilgili torrente hash ile ulaşalım (sıradan bağımsız)
let old_t = old_map.get(new_t.hash.as_str()).unwrap();
let mut update = TorrentUpdate {
hash: new_t.hash.clone(),
name: None,
size: None,
down_rate: None,
up_rate: None,
percent_complete: None,
completed: None,
eta: None,
status: None,
error_message: None,
label: None,
};
// Manuel diff creating TorrentUpdate (which is the Patch struct)
let mut patch = TorrentUpdate::default();
let mut has_changes = false;
// Alanları karşılaştır
if old_t.name != new_t.name {
update.name = Some(new_t.name.clone());
has_changes = true;
}
if old_t.size != new_t.size {
update.size = Some(new_t.size);
has_changes = true;
}
if old_t.down_rate != new_t.down_rate {
update.down_rate = Some(new_t.down_rate);
has_changes = true;
}
if old_t.up_rate != new_t.up_rate {
update.up_rate = Some(new_t.up_rate);
has_changes = true;
}
if (old_t.percent_complete - new_t.percent_complete).abs() > 0.01 {
update.percent_complete = Some(new_t.percent_complete);
has_changes = true;
// Torrent tamamlanma kontrolü
if old_t.name != new_t.name { patch.name = Some(new_t.name.clone()); has_changes = true; }
if old_t.size != new_t.size { patch.size = Some(new_t.size); has_changes = true; }
if old_t.down_rate != new_t.down_rate { patch.down_rate = Some(new_t.down_rate); has_changes = true; }
if old_t.up_rate != new_t.up_rate { patch.up_rate = Some(new_t.up_rate); has_changes = true; }
if old_t.completed != new_t.completed { patch.completed = Some(new_t.completed); has_changes = true; }
if old_t.eta != new_t.eta { patch.eta = Some(new_t.eta); has_changes = true; }
if (old_t.percent_complete - new_t.percent_complete).abs() > 0.01 {
patch.percent_complete = Some(new_t.percent_complete);
has_changes = true;
if old_t.percent_complete < 100.0 && new_t.percent_complete >= 100.0 {
tracing::info!("Torrent completed: {} ({})", new_t.name, new_t.hash);
events.push(AppEvent::Notification(SystemNotification {
level: NotificationLevel::Success,
message: format!("Torrent tamamlandı: {}", new_t.name),
}));
}
}
if old_t.completed != new_t.completed {
update.completed = Some(new_t.completed);
has_changes = true;
}
if old_t.eta != new_t.eta {
update.eta = Some(new_t.eta);
has_changes = true;
}
if old_t.status != new_t.status {
update.status = Some(new_t.status.clone());
has_changes = true;
tracing::debug!(
"Torrent status changed: {} ({}) {:?} -> {:?}",
new_t.name, new_t.hash, old_t.status, new_t.status
);
}
if old_t.error_message != new_t.error_message {
update.error_message = Some(new_t.error_message.clone());
has_changes = true;
}
if old_t.label != new_t.label {
update.label = new_t.label.clone();
has_changes = true;
}
if old_t.status != new_t.status { patch.status = Some(new_t.status.clone()); has_changes = true; }
if old_t.error_message != new_t.error_message { patch.error_message = Some(new_t.error_message.clone()); has_changes = true; }
if old_t.label != new_t.label { patch.label = Some(new_t.label.clone()); has_changes = true; }
if has_changes {
events.push(AppEvent::Update(update));
// Set the hash (not an Option in Patch usually, but check shared/src/lib.rs)
// Wait, TorrentUpdate is a Patch, does it have 'hash' field?
// Yes, because Torrent has 'hash' field.
patch.hash = Some(new_t.hash.clone());
events.push(AppEvent::Update(patch));
}
}

View File

@@ -1,13 +1,4 @@
use shared::{
xmlrpc::{self, RpcParam},
AddTorrentRequest, GlobalLimitRequest, SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest,
TorrentFile, TorrentPeer, TorrentTracker,
};
use crate::AppState;
#[cfg(feature = "push-notifications")]
use crate::push;
use axum::{
extract::{Json, Path, State},
http::{header, StatusCode, Uri},
response::IntoResponse,
BoxError,
@@ -37,7 +28,6 @@ pub async fn static_handler(uri: Uri) -> impl IntoResponse {
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();
@@ -49,614 +39,6 @@ pub async fn static_handler(uri: Uri) -> impl IntoResponse {
}
}
// --- TORRENT ACTIONS ---
/// Add a new torrent via magnet link or URL
#[utoipa::path(
post,
path = "/api/torrents/add",
request_body = AddTorrentRequest,
responses(
(status = 200, description = "Torrent added successfully"),
(status = 500, description = "Internal server error or rTorrent fault")
)
)]
pub async fn add_torrent_handler(
State(state): State<AppState>,
Json(payload): Json<AddTorrentRequest>,
) -> StatusCode {
tracing::info!(
"Received add_torrent request. URI length: {}",
payload.uri.len()
);
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![RpcParam::from(""), RpcParam::from(payload.uri.as_str())];
match client.call("load.start", &params).await {
Ok(response) => {
tracing::debug!("rTorrent response to load.start: {}", response);
if response.contains("faultCode") {
tracing::error!("rTorrent returned fault: {}", response);
return StatusCode::INTERNAL_SERVER_ERROR;
}
// Note: Frontend shows its own toast, no SSE notification needed
StatusCode::OK
}
Err(e) => {
tracing::error!("Failed to add torrent: {}", e);
// Note: Frontend shows its own toast, no SSE notification needed
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
/// Perform an action on a torrent (start, stop, delete)
#[utoipa::path(
post,
path = "/api/torrents/action",
request_body = TorrentActionRequest,
responses(
(status = 200, description = "Action executed successfully"),
(status = 400, description = "Invalid action or request"),
(status = 403, description = "Forbidden: Security risk detected"),
(status = 500, description = "Internal server error")
)
)]
pub async fn handle_torrent_action(
State(state): State<AppState>,
Json(payload): Json<TorrentActionRequest>,
) -> impl IntoResponse {
tracing::info!(
"Received action: {} for hash: {}",
payload.action,
payload.hash
);
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
// Special handling for delete_with_data
if payload.action == "delete_with_data" {
return match delete_torrent_with_data(&client, &payload.hash).await {
Ok(msg) => {
// Note: Frontend shows its own toast
(StatusCode::OK, msg).into_response()
}
Err((status, msg)) => (status, msg).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(),
};
let params = vec![RpcParam::from(payload.hash.as_str())];
match client.call(method, &params).await {
Ok(_) => {
// Note: Frontend shows its own toast, no SSE notification needed
(StatusCode::OK, "Action executed").into_response()
}
Err(e) => {
tracing::error!("RPC error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to execute action",
)
.into_response()
}
}
}
/// Helper function to handle secure deletion of torrent data
async fn delete_torrent_with_data(
client: &xmlrpc::RtorrentClient,
hash: &str,
) -> Result<&'static str, (StatusCode, String)> {
let params_hash = vec![RpcParam::from(hash)];
// 1. Get Base Path
let path_xml = client
.call("d.base_path", &params_hash)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to call rTorrent: {}", e),
)
})?;
let path = xmlrpc::parse_string_response(&path_xml).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to parse path: {}", e),
)
})?;
// 1.5 Get Default Download Directory (Sandbox Root)
let root_xml = client.call("directory.default", &[]).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get valid download root: {}", e),
)
})?;
let root_path_str = xmlrpc::parse_string_response(&root_xml).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to parse root path: {}", e),
)
})?;
// Resolve Paths (Canonicalize) to prevent .. traversal and symlink attacks
let root_path = tokio::fs::canonicalize(std::path::Path::new(&root_path_str))
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Invalid download root configuration (on server): {}", e),
)
})?;
// Check if target path exists before trying to resolve it
let target_path_raw = std::path::Path::new(&path);
if !tokio::fs::try_exists(target_path_raw)
.await
.unwrap_or(false)
{
tracing::warn!(
"Data path not found: {:?}. Removing torrent only.",
target_path_raw
);
// If file doesn't exist, we just remove the torrent entry
client.call("d.erase", &params_hash).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to erase torrent: {}", e),
)
})?;
return Ok("Torrent removed (Data not found)");
}
let target_path = tokio::fs::canonicalize(target_path_raw)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Invalid data path: {}", e),
)
})?;
tracing::info!(
"Delete request: Target='{:?}', Root='{:?}'",
target_path,
root_path
);
// SECURITY CHECK: Ensure path is inside root_path
if !target_path.starts_with(&root_path) {
tracing::error!(
"Security Risk: Attempted to delete path outside download directory: {:?}",
target_path
);
return Err((
StatusCode::FORBIDDEN,
"Security Error: Cannot delete files outside default download directory".to_string(),
));
}
// SECURITY CHECK: Ensure we are not deleting the root itself
if target_path == root_path {
return Err((
StatusCode::BAD_REQUEST,
"Security Error: Cannot delete the download root directory itself".to_string(),
));
}
// 2. Erase Torrent first
client.call("d.erase", &params_hash).await.map_err(|e| {
tracing::warn!("Failed to erase torrent entry: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to erase torrent: {}", e),
)
})?;
// 3. Delete Files via Native FS
let delete_result = if target_path.is_dir() {
tokio::fs::remove_dir_all(&target_path).await
} else {
tokio::fs::remove_file(&target_path).await
};
match delete_result {
Ok(_) => Ok("Torrent and data deleted"),
Err(e) => {
tracing::error!("Failed to delete data at {:?}: {}", target_path, e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to delete data: {}", e),
))
}
}
}
// --- NEW HANDLERS ---
/// Get rTorrent version
#[utoipa::path(
get,
path = "/api/system/version",
responses(
(status = 200, description = "rTorrent version", body = String),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_version_handler(State(state): State<AppState>) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
match client.call("system.client_version", &[]).await {
Ok(xml) => {
let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml);
(StatusCode::OK, version).into_response()
}
Err(e) => {
tracing::error!("Failed to get version: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to get version").into_response()
}
}
}
/// Get files for a torrent
#[utoipa::path(
get,
path = "/api/torrents/{hash}/files",
responses(
(status = 200, description = "Files list", body = Vec<TorrentFile>),
(status = 500, description = "Internal server error")
),
params(
("hash" = String, Path, description = "Torrent Hash")
)
)]
pub async fn get_files_handler(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("f.path="),
RpcParam::from("f.size_bytes="),
RpcParam::from("f.completed_chunks="),
RpcParam::from("f.priority="),
];
match client.call("f.multicall", &params).await {
Ok(xml) => match xmlrpc::parse_multicall_response(&xml) {
Ok(rows) => {
let files: Vec<TorrentFile> = rows
.into_iter()
.enumerate()
.map(|(idx, row)| TorrentFile {
index: idx as u32,
path: row.get(0).cloned().unwrap_or_default(),
size: row.get(1).and_then(|s| s.parse().ok()).unwrap_or(0),
completed_chunks: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
priority: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
})
.collect();
(StatusCode::OK, Json(files)).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Parse error: {}", e),
)
.into_response(),
},
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Get peers for a torrent
#[utoipa::path(
get,
path = "/api/torrents/{hash}/peers",
responses(
(status = 200, description = "Peers list", body = Vec<TorrentPeer>),
(status = 500, description = "Internal server error")
),
params(
("hash" = String, Path, description = "Torrent Hash")
)
)]
pub async fn get_peers_handler(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("p.address="),
RpcParam::from("p.client_version="),
RpcParam::from("p.down_rate="),
RpcParam::from("p.up_rate="),
RpcParam::from("p.completed_percent="),
];
match client.call("p.multicall", &params).await {
Ok(xml) => match xmlrpc::parse_multicall_response(&xml) {
Ok(rows) => {
let peers: Vec<TorrentPeer> = rows
.into_iter()
.map(|row| TorrentPeer {
ip: row.get(0).cloned().unwrap_or_default(),
client: row.get(1).cloned().unwrap_or_default(),
down_rate: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
up_rate: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
progress: row.get(4).and_then(|s| s.parse().ok()).unwrap_or(0.0),
})
.collect();
(StatusCode::OK, Json(peers)).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Parse error: {}", e),
)
.into_response(),
},
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Get trackers for a torrent
#[utoipa::path(
get,
path = "/api/torrents/{hash}/trackers",
responses(
(status = 200, description = "Trackers list", body = Vec<TorrentTracker>),
(status = 500, description = "Internal server error")
),
params(
("hash" = String, Path, description = "Torrent Hash")
)
)]
pub async fn get_trackers_handler(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("t.url="),
RpcParam::from("t.activity_date_last="),
RpcParam::from("t.message="),
];
match client.call("t.multicall", &params).await {
Ok(xml) => {
match xmlrpc::parse_multicall_response(&xml) {
Ok(rows) => {
let trackers: Vec<TorrentTracker> = rows
.into_iter()
.map(|row| {
TorrentTracker {
url: row.get(0).cloned().unwrap_or_default(),
status: "Unknown".to_string(), // Derive from type/activity?
message: row.get(2).cloned().unwrap_or_default(),
}
})
.collect();
(StatusCode::OK, Json(trackers)).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Parse error: {}", e),
)
.into_response(),
}
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Set file priority
#[utoipa::path(
post,
path = "/api/torrents/files/priority",
request_body = SetFilePriorityRequest,
responses(
(status = 200, description = "Priority updated"),
(status = 500, description = "Internal server error")
)
)]
pub async fn set_file_priority_handler(
State(state): State<AppState>,
Json(payload): Json<SetFilePriorityRequest>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
// f.set_priority takes "hash", index, priority
// Priority: 0 (off), 1 (normal), 2 (high)
// f.set_priority is tricky. Let's send as string first as before, or int if we knew.
// Usually priorities are small integers.
// But since we are updating everything to RpcParam, let's use Int if possible or String.
// The previous implementation used string. Let's stick to string for now or try Int.
// Actually, f.set_priority likely takes an integer.
let target = format!("{}:f{}", payload.hash, payload.file_index);
let params = vec![
RpcParam::from(target.as_str()),
RpcParam::from(payload.priority as i64),
];
match client.call("f.set_priority", &params).await {
Ok(_) => {
let _ = client
.call(
"d.update_priorities",
&[RpcParam::from(payload.hash.as_str())],
)
.await;
(StatusCode::OK, "Priority updated").into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Set torrent label
#[utoipa::path(
post,
path = "/api/torrents/label",
request_body = SetLabelRequest,
responses(
(status = 200, description = "Label updated"),
(status = 500, description = "Internal server error")
)
)]
pub async fn set_label_handler(
State(state): State<AppState>,
Json(payload): Json<SetLabelRequest>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![
RpcParam::from(payload.hash.as_str()),
RpcParam::from(payload.label),
];
match client.call("d.custom1.set", &params).await {
Ok(_) => (StatusCode::OK, "Label updated").into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Get global speed limits
#[utoipa::path(
get,
path = "/api/settings/global-limits",
responses(
(status = 200, description = "Current limits", body = GlobalLimitRequest),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_global_limit_handler(State(state): State<AppState>) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
// throttle.global_down.max_rate, throttle.global_up.max_rate
let down_fut = client.call("throttle.global_down.max_rate", &[]);
let up_fut = client.call("throttle.global_up.max_rate", &[]);
let down = match down_fut.await {
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
Err(_) => -1,
};
let up = match up_fut.await {
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
Err(_) => -1,
};
let resp = GlobalLimitRequest {
max_download_rate: Some(down),
max_upload_rate: Some(up),
};
(StatusCode::OK, Json(resp)).into_response()
}
/// Set global speed limits
#[utoipa::path(
post,
path = "/api/settings/global-limits",
request_body = GlobalLimitRequest,
responses(
(status = 200, description = "Limits updated"),
(status = 500, description = "Internal server error")
)
)]
pub async fn set_global_limit_handler(
State(state): State<AppState>,
Json(payload): Json<GlobalLimitRequest>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
// Use throttle.global_*.max_rate.set_kb which is more reliable than .set (which is buggy)
// The .set_kb method expects KB/s, so we convert bytes to KB
if let Some(down) = payload.max_download_rate {
// Convert bytes/s to KB/s (divide by 1024)
let down_kb = down / 1024;
tracing::info!(
"Setting download limit: {} bytes/s = {} KB/s",
down,
down_kb
);
// Use set_kb with empty string as first param (throttle name), then value
if let Err(e) = client
.call(
"throttle.global_down.max_rate.set_kb",
&[RpcParam::from(""), RpcParam::Int(down_kb)],
)
.await
{
tracing::error!("Failed to set download limit: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to set down limit: {}", e),
)
.into_response();
}
}
if let Some(up) = payload.max_upload_rate {
// Convert bytes/s to KB/s
let up_kb = up / 1024;
tracing::info!("Setting upload limit: {} bytes/s = {} KB/s", up, up_kb);
if let Err(e) = client
.call(
"throttle.global_up.max_rate.set_kb",
&[RpcParam::from(""), RpcParam::Int(up_kb)],
)
.await
{
tracing::error!("Failed to set upload limit: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to set up limit: {}", e),
)
.into_response();
}
}
(StatusCode::OK, "Limits updated").into_response()
}
pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
if err.is::<tower::timeout::error::Elapsed>() {
(StatusCode::REQUEST_TIMEOUT, "Request timed out")
@@ -667,44 +49,3 @@ pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
)
}
}
// --- PUSH NOTIFICATION HANDLERS ---
#[cfg(feature = "push-notifications")]
/// Get VAPID public key for push subscription
#[utoipa::path(
get,
path = "/api/push/public-key",
responses(
(status = 200, description = "VAPID public key", body = String)
)
)]
pub async fn get_push_public_key_handler(
State(state): State<AppState>,
) -> impl IntoResponse {
let public_key = state.push_store.get_public_key();
(StatusCode::OK, Json(serde_json::json!({ "publicKey": public_key }))).into_response()
}
#[cfg(feature = "push-notifications")]
/// Subscribe to push notifications
#[utoipa::path(
post,
path = "/api/push/subscribe",
request_body = push::PushSubscription,
responses(
(status = 200, description = "Subscription saved"),
(status = 400, description = "Invalid subscription data")
)
)]
pub async fn subscribe_push_handler(
State(state): State<AppState>,
Json(subscription): Json<push::PushSubscription>,
) -> impl IntoResponse {
tracing::info!("Received push subscription: {:?}", subscription);
state.push_store.add_subscription(subscription).await;
(StatusCode::OK, "Subscription saved").into_response()
}

View File

@@ -1,4 +1,3 @@
mod db;
mod diff;
mod handlers;
#[cfg(feature = "push-notifications")]
@@ -42,7 +41,7 @@ pub struct AppState {
pub tx: Arc<watch::Sender<Vec<Torrent>>>,
pub event_bus: broadcast::Sender<AppEvent>,
pub scgi_socket_path: String,
pub db: db::Db,
pub db: shared::db::Db,
#[cfg(feature = "push-notifications")]
pub push_store: push::PushSubscriptionStore,
pub notify_poll: Arc<tokio::sync::Notify>,
@@ -56,10 +55,9 @@ async fn auth_middleware(
) -> Result<Response, StatusCode> {
// Skip auth for public paths
let path = request.uri().path();
if path.starts_with("/api/auth/login")
|| path.starts_with("/api/auth/check") // Used by frontend to decide where to go
|| path.starts_with("/api/setup")
|| path.starts_with("/api/server_fns")
if path.starts_with("/api/server_fns/Login") // Login server fn
|| path.starts_with("/api/server_fns/GetSetupStatus")
|| path.starts_with("/api/server_fns/Setup")
|| path.starts_with("/swagger-ui")
|| path.starts_with("/api-docs")
|| !path.starts_with("/api/") // Allow static files (frontend)
@@ -69,9 +67,19 @@ async fn auth_middleware(
// Check token
if let Some(token) = jar.get("auth_token") {
match state.db.get_session_user(token.value()).await {
Ok(Some(_)) => return Ok(next.run(request).await),
_ => {} // Invalid
use jsonwebtoken::{decode, Validation, DecodingKey};
use shared::server_fns::auth::Claims;
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
let validation = Validation::default();
match decode::<Claims>(
token.value(),
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
) {
Ok(_) => return Ok(next.run(request).await),
Err(_) => {} // Invalid token
}
}
@@ -103,69 +111,9 @@ struct Args {
}
#[cfg(feature = "swagger")]
#[cfg(feature = "push-notifications")]
#[derive(OpenApi)]
#[openapi(
paths(
handlers::add_torrent_handler,
handlers::handle_torrent_action,
handlers::get_version_handler,
handlers::get_files_handler,
handlers::get_peers_handler,
handlers::get_trackers_handler,
handlers::set_file_priority_handler,
handlers::set_label_handler,
handlers::get_global_limit_handler,
handlers::set_global_limit_handler,
handlers::get_push_public_key_handler,
handlers::subscribe_push_handler,
handlers::auth::login_handler,
handlers::auth::logout_handler,
handlers::auth::check_auth_handler,
handlers::setup::setup_handler,
handlers::setup::get_setup_status_handler
),
components(
schemas(
shared::AddTorrentRequest,
shared::TorrentActionRequest,
shared::Torrent,
shared::TorrentStatus,
shared::TorrentFile,
shared::TorrentPeer,
shared::TorrentTracker,
shared::SetFilePriorityRequest,
shared::SetLabelRequest,
shared::GlobalLimitRequest,
push::PushSubscription,
push::PushKeys,
handlers::auth::LoginRequest,
handlers::setup::SetupRequest,
handlers::setup::SetupStatusResponse,
handlers::auth::UserResponse
)
),
tags(
(name = "vibetorrent", description = "VibeTorrent API")
)
)]
struct ApiDoc;
#[cfg(feature = "swagger")]
#[cfg(not(feature = "push-notifications"))]
#[derive(OpenApi)]
#[openapi(
paths(
handlers::add_torrent_handler,
handlers::handle_torrent_action,
handlers::get_version_handler,
handlers::get_files_handler,
handlers::get_peers_handler,
handlers::get_trackers_handler,
handlers::set_file_priority_handler,
handlers::set_label_handler,
handlers::get_global_limit_handler,
handlers::set_global_limit_handler,
handlers::auth::login_handler,
handlers::auth::logout_handler,
handlers::auth::check_auth_handler,
@@ -214,19 +162,9 @@ async fn main() {
// Initialize Database
tracing::info!("Connecting to database: {}", args.db_url);
// Ensure the db file exists if it's sqlite
if args.db_url.starts_with("sqlite:") {
let path = args.db_url.trim_start_matches("sqlite:");
if !std::path::Path::new(path).exists() {
tracing::info!("Database file not found, creating: {}", path);
match std::fs::File::create(path) {
Ok(_) => tracing::info!("Created empty database file"),
Err(e) => tracing::error!("Failed to create database file: {}", e),
}
}
}
// Redundant manual creation removed, shared::db handles it
let db: db::Db = match db::Db::new(&args.db_url).await {
let db: shared::db::Db = match shared::db::Db::new(&args.db_url).await {
Ok(db) => db,
Err(e) => {
tracing::error!("Failed to connect to database: {}", e);
@@ -336,7 +274,7 @@ async fn main() {
};
#[cfg(not(feature = "push-notifications"))]
let push_store = ();
let _push_store = ();
let notify_poll = Arc::new(tokio::sync::Notify::new());
@@ -488,54 +426,31 @@ async fn main() {
#[cfg(feature = "swagger")]
let app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));
// Setup & Auth Routes
// Setup & Auth Routes (cookie-based, stay as REST)
let scgi_path_for_ctx = args.socket.clone();
let db_for_ctx = db.clone();
let app = app
.route("/api/setup/status", get(handlers::setup::get_setup_status_handler))
.route("/api/setup", post(handlers::setup::setup_handler))
.route(
"/api/auth/login",
post(handlers::auth::login_handler).layer(GovernorLayer::new(Arc::new(
rate_limit::get_login_rate_limit_config(),
))),
)
.route("/api/auth/logout", post(handlers::auth::logout_handler))
.route("/api/auth/check", get(handlers::auth::check_auth_handler))
// App Routes
.route("/api/events", get(sse::sse_handler))
.route("/api/torrents/add", post(handlers::add_torrent_handler))
.route(
"/api/torrents/action",
post(handlers::handle_torrent_action),
)
.route("/api/system/version", get(handlers::get_version_handler))
.route(
"/api/torrents/{hash}/files",
get(handlers::get_files_handler),
)
.route(
"/api/torrents/{hash}/peers",
get(handlers::get_peers_handler),
)
.route(
"/api/torrents/{hash}/trackers",
get(handlers::get_trackers_handler),
)
.route(
"/api/torrents/files/priority",
post(handlers::set_file_priority_handler),
)
.route("/api/torrents/label", post(handlers::set_label_handler))
.route(
"/api/settings/global-limits",
get(handlers::get_global_limit_handler).post(handlers::set_global_limit_handler),
)
.route("/api/server_fns/{*fn_name}", post(leptos_axum::handle_server_fns))
.fallback(handlers::static_handler); // Serve static files for everything else
#[cfg(feature = "push-notifications")]
let app = app
.route("/api/push/public-key", get(handlers::get_push_public_key_handler))
.route("/api/push/subscribe", post(handlers::subscribe_push_handler));
.route("/api/server_fns/{*fn_name}", post({
let scgi_path = scgi_path_for_ctx.clone();
let db = db_for_ctx.clone();
move |req: Request<Body>| {
let scgi_path = scgi_path.clone();
let db = db.clone();
leptos_axum::handle_server_fns_with_context(
move || {
leptos::context::provide_context(shared::ServerContext {
scgi_socket_path: scgi_path.clone(),
});
leptos::context::provide_context(shared::DbContext {
db: db.clone(),
});
},
req,
)
}
}))
.fallback(handlers::static_handler);
let app = app
.layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware))

View File

@@ -7,7 +7,7 @@ use web_push::{
};
use futures::StreamExt;
use crate::db::Db;
use shared::db::Db;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PushSubscription {

View File

@@ -8,6 +8,8 @@ use futures::stream::{self, Stream};
use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus};
use std::convert::Infallible;
use tokio_stream::StreamExt;
use axum::response::IntoResponse;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
// Field definitions to keep query and parser in sync
mod fields {
@@ -194,7 +196,7 @@ pub async fn fetch_global_stats(client: &RtorrentClient) -> Result<GlobalStats,
pub async fn sse_handler(
State(state): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
) -> impl IntoResponse {
// Notify background worker to wake up and poll immediately
state.notify_poll.notify_one();
@@ -213,8 +215,8 @@ pub async fn sse_handler(
timestamp,
};
match serde_json::to_string(&event_data) {
Ok(json) => Event::default().data(json),
match rmp_serde::to_vec(&event_data) {
Ok(bytes) => Event::default().data(BASE64.encode(bytes)),
Err(_) => Event::default().comment("init_error"),
}
};
@@ -226,10 +228,10 @@ pub async fn sse_handler(
let rx = state.event_bus.subscribe();
let update_stream = stream::unfold(rx, |mut rx| async move {
match rx.recv().await {
Ok(event) => match serde_json::to_string(&event) {
Ok(json) => Some((Ok::<Event, Infallible>(Event::default().data(json)), rx)),
Ok(event) => match rmp_serde::to_vec(&event) {
Ok(bytes) => Some((Ok::<Event, Infallible>(Event::default().data(BASE64.encode(bytes))), rx)),
Err(e) => {
tracing::warn!("Failed to serialize SSE event: {}", e);
tracing::warn!("Failed to serialize SSE event (MessagePack): {}", e);
Some((
Ok::<Event, Infallible>(Event::default().comment("error")),
rx,
@@ -244,6 +246,11 @@ pub async fn sse_handler(
}
});
Sse::new(initial_stream.chain(update_stream))
.keep_alive(axum::response::sse::KeepAlive::default())
}
let sse = Sse::new(initial_stream.chain(update_stream))
.keep_alive(axum::response::sse::KeepAlive::default());
(
[("content-type", "application/x-msgpack")],
sse
)
}

View File

@@ -31,3 +31,6 @@ serde-wasm-bindgen = "0.6.5"
leptos-use = { version = "0.16", features = ["storage"] }
codee = "0.3"
thiserror = "2.0"
rmp-serde = "1.3"
struct-patch = "0.5"
leptos-shadcn-ui = { version = "0.9.0", default-features = false, features = ["button", "input"] }

View File

@@ -1,16 +1,130 @@
@import "tailwindcss";
@config "./tailwind.config.js";
@plugin "daisyui" {
themes:
light, dark, dim, nord, cupcake, dracula, cyberpunk, emerald, sunset,
abyss;
@theme {
/* Shadcn Colors */
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
@layer base {
html,
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply min-h-dvh w-full overflow-hidden bg-base-100 text-base-content overscroll-y-none;
@apply bg-background text-foreground;
}
}

View File

@@ -11,7 +11,6 @@
"license": "ISC",
"devDependencies": {
"autoprefixer": "^10.4.23",
"daisyui": "^5.5.1-beta.2",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18"
},

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
use gloo_net::http::Request;
use shared::{AddTorrentRequest, TorrentActionRequest};
use thiserror::Error;
#[derive(Debug, Error)]
@@ -14,6 +13,8 @@ pub enum ApiError {
Unauthorized,
#[error("Too many requests")]
RateLimited,
#[error("Server function error: {0}")]
ServerFn(String),
}
fn base_url() -> String {
@@ -130,37 +131,32 @@ pub mod settings {
use shared::GlobalLimitRequest;
pub async fn set_global_limits(req: &GlobalLimitRequest) -> Result<(), ApiError> {
Request::post(&format!("{}/settings/global-limits", base_url()))
.json(req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
shared::server_fns::settings::set_global_limits(
req.max_download_rate,
req.max_upload_rate,
)
.await
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
}
pub mod push {
use super::*;
use crate::store::PushSubscriptionData;
pub async fn get_public_key() -> Result<String, ApiError> {
let resp = Request::get(&format!("{}/push/public-key", base_url()))
.send()
shared::server_fns::push::get_public_key()
.await
.map_err(|_| ApiError::Network)?;
let key = resp.text().await.map_err(|_| ApiError::Network)?;
Ok(key)
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
pub async fn subscribe(req: &PushSubscriptionData) -> Result<(), ApiError> {
Request::post(&format!("{}/push/subscribe", base_url()))
.json(req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
pub async fn subscribe(endpoint: &str, p256dh: &str, auth: &str) -> Result<(), ApiError> {
shared::server_fns::push::subscribe_push(
endpoint.to_string(),
p256dh.to_string(),
auth.to_string(),
)
.await
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
}
@@ -168,30 +164,16 @@ pub mod torrent {
use super::*;
pub async fn add(uri: &str) -> Result<(), ApiError> {
let req = AddTorrentRequest {
uri: uri.to_string(),
};
Request::post(&format!("{}/torrents/add", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
shared::server_fns::torrent::add_torrent(uri.to_string())
.await
.map_err(|_| ApiError::Network)?;
Ok(())
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
pub async fn action(hash: &str, action: &str) -> Result<(), ApiError> {
let req = TorrentActionRequest {
hash: hash.to_string(),
action: action.to_string(),
};
Request::post(&format!("{}/torrents/action", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
shared::server_fns::torrent::torrent_action(hash.to_string(), action.to_string())
.await
.map_err(|_| ApiError::Network)?;
Ok(())
.map(|_| ())
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
pub async fn delete(hash: &str) -> Result<(), ApiError> {
@@ -211,33 +193,18 @@ pub mod torrent {
}
pub async fn set_label(hash: &str, label: &str) -> Result<(), ApiError> {
use shared::SetLabelRequest;
let req = SetLabelRequest {
hash: hash.to_string(),
label: label.to_string(),
};
Request::post(&format!("{}/torrents/set_label", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
shared::server_fns::torrent::set_label(hash.to_string(), label.to_string())
.await
.map_err(|_| ApiError::Network)?;
Ok(())
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
pub async fn set_priority(hash: &str, file_index: u32, priority: u8) -> Result<(), ApiError> {
use shared::SetFilePriorityRequest;
let req = SetFilePriorityRequest {
hash: hash.to_string(),
shared::server_fns::torrent::set_file_priority(
hash.to_string(),
file_index,
priority,
};
Request::post(&format!("{}/torrents/set_priority", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
)
.await
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
}

View File

@@ -12,22 +12,22 @@ use leptos_router::hooks::use_navigate;
#[component]
pub fn App() -> impl IntoView {
crate::store::provide_torrent_store();
let store = use_context::<crate::store::TorrentStore>();
let is_loading = signal(true);
let is_authenticated = signal(false);
let needs_setup = signal(false);
Effect::new(move |_| {
spawn_local(async move {
log::info!("App initialization started...");
let setup_res = api::setup::get_status().await;
match setup_res {
// Check if setup is needed via Server Function
match shared::server_fns::auth::get_setup_status().await {
Ok(status) => {
if !status.completed {
log::info!("Setup not completed, redirecting to /setup");
let navigate = use_navigate();
navigate("/setup", Default::default());
log::info!("Setup not completed");
needs_setup.1.set(true);
is_loading.1.set(false);
return;
}
@@ -35,40 +35,20 @@ pub fn App() -> impl IntoView {
Err(e) => log::error!("Failed to get setup status: {:?}", e),
}
let auth_res = api::auth::check_auth().await;
match auth_res {
Ok(true) => {
log::info!("Authenticated!");
if let Ok(user_info) = api::auth::get_user().await {
if let Some(store) = use_context::<crate::store::TorrentStore>() {
store.user.set(Some(user_info.username));
}
// Check authentication via GetUser Server Function
match shared::server_fns::auth::get_user().await {
Ok(Some(user_info)) => {
log::info!("Authenticated as {}", user_info.username);
if let Some(s) = store {
s.user.set(Some(user_info.username));
}
is_authenticated.1.set(true);
let pathname = window().location().pathname().unwrap_or_default();
if pathname == "/login" || pathname == "/setup" {
log::info!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
}
Ok(false) => {
Ok(None) => {
log::info!("Not authenticated");
let pathname = window().location().pathname().unwrap_or_default();
if pathname != "/login" && pathname != "/setup" {
let navigate = use_navigate();
navigate("/login", Default::default());
}
}
Err(e) => {
log::error!("Auth check failed: {:?}", e);
let navigate = use_navigate();
navigate("/login", Default::default());
}
}
@@ -92,14 +72,55 @@ pub fn App() -> impl IntoView {
<div class="relative w-full h-screen" style="height: 100dvh;">
<Router>
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
<Route path=leptos_router::path!("/login") view=move || view! { <Login /> } />
<Route path=leptos_router::path!("/setup") view=move || view! { <Setup /> } />
<Route path=leptos_router::path!("/login") view=move || {
let authenticated = is_authenticated.0.get();
let setup_needed = needs_setup.0.get();
Effect::new(move |_| {
if setup_needed {
let navigate = use_navigate();
navigate("/setup", Default::default());
} else if authenticated {
log::info!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Login /> }
} />
<Route path=leptos_router::path!("/setup") view=move || {
Effect::new(move |_| {
if is_authenticated.0.get() {
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Setup /> }
} />
<Route path=leptos_router::path!("/") view=move || {
let navigate = use_navigate();
Effect::new(move |_| {
if !is_loading.0.get() {
if needs_setup.0.get() {
log::info!("Setup not completed, redirecting to setup");
navigate("/setup", Default::default());
} else if !is_authenticated.0.get() {
log::info!("Not authenticated, redirecting to login");
navigate("/login", Default::default());
}
}
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| view! {
<div class="flex items-center justify-center h-screen bg-base-100">
<span class="loading loading-spinner loading-lg"></span>
<div class="flex items-center justify-center h-screen bg-background">
<div class="flex flex-col items-center gap-4">
<div class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
<p class="text-sm text-muted-foreground">"Yükleniyor..."</p>
</div>
</div>
}>
<Show when=move || is_authenticated.0.get() fallback=|| ()>
@@ -112,6 +133,13 @@ pub fn App() -> impl IntoView {
}/>
<Route path=leptos_router::path!("/settings") view=move || {
Effect::new(move |_| {
if !is_authenticated.0.get() {
let navigate = use_navigate();
navigate("/login", Default::default());
}
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| ()>
<Show when=move || is_authenticated.0.get() fallback=|| ()>
@@ -128,4 +156,4 @@ pub fn App() -> impl IntoView {
<ToastContainer />
</div>
}
}
}

View File

@@ -1,12 +1,10 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::api;
#[component]
pub fn Login() -> impl IntoView {
let username = signal(String::new());
let password = signal(String::new());
let remember_me = signal(false);
let error = signal(Option::<String>::None);
let loading = signal(false);
@@ -17,12 +15,11 @@ pub fn Login() -> impl IntoView {
let user = username.0.get();
let pass = password.0.get();
let rem = remember_me.0.get();
log::info!("Attempting login for user: {}", user);
spawn_local(async move {
match api::auth::login(&user, &pass, rem).await {
match shared::server_fns::auth::login(user, pass).await {
Ok(_) => {
log::info!("Login successful, redirecting...");
let window = web_sys::window().expect("window should exist");
@@ -38,43 +35,43 @@ pub fn Login() -> impl IntoView {
};
view! {
<div class="flex items-center justify-center min-h-screen bg-base-200">
<div class="card w-full max-w-sm shadow-xl bg-base-100">
<div class="card-body">
<div class="flex flex-col items-center mb-6">
<div class="w-16 h-16 bg-primary rounded-2xl flex items-center justify-center text-primary-content shadow-lg mb-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-10 h-10">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.25 0 003 2.48z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z" />
</svg>
</div>
<h2 class="card-title text-2xl font-bold">"VibeTorrent"</h2>
<p class="text-base-content/60 text-sm">"Hesabınıza giriş yapın"</p>
<div class="flex items-center justify-center min-h-screen bg-muted/40">
<div class="w-full max-w-sm rounded-xl border border-border bg-card text-card-foreground shadow-lg">
<div class="flex flex-col space-y-1.5 p-6 pb-2 items-center">
<div class="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-sm mb-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.25 0 003 2.48z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z" />
</svg>
</div>
<h3 class="font-semibold tracking-tight text-2xl">"VibeTorrent"</h3>
<p class="text-sm text-muted-foreground">"Hesabınıza giriş yapın"</p>
</div>
<div class="p-6 pt-4">
<form on:submit=handle_login class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text">"Kullanıcı Adı"</span>
<div class="space-y-2">
<label class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
"Kullanıcı Adı"
</label>
<input
type="text"
placeholder="Kullanıcı adınız"
class="input input-bordered w-full"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
prop:value=move || username.0.get()
on:input=move |ev| username.1.set(event_target_value(&ev))
disabled=move || loading.0.get()
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">"Şifre"</span>
<div class="space-y-2">
<label class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
"Şifre"
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
prop:value=move || password.0.get()
on:input=move |ev| password.1.set(event_target_value(&ev))
disabled=move || loading.0.get()
@@ -82,32 +79,21 @@ pub fn Login() -> impl IntoView {
/>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
prop:checked=move || remember_me.0.get()
on:change=move |ev| remember_me.1.set(event_target_checked(&ev))
/>
<span class="label-text">"Beni hatırla"</span>
</label>
</div>
<Show when=move || error.0.get().is_some() fallback=|| ()>
<div class="alert alert-error text-xs py-2 shadow-sm">
<div class="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive dark:border-destructive dark:bg-destructive/50 dark:text-destructive-foreground">
<span>{move || error.0.get().unwrap_or_default()}</span>
</div>
</Show>
<div class="form-control mt-6">
<div class="pt-2">
<button
class="btn btn-primary w-full"
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2 w-full"
type="submit"
disabled=move || loading.0.get()
>
<Show when=move || loading.0.get() fallback=|| "Giriş Yap">
<span class="loading loading-spinner"></span>
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
"Giriş Yapılıyor..."
</Show>
</button>
</div>

View File

@@ -1,6 +1,5 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::api;
#[component]
pub fn Setup() -> impl IntoView {
@@ -32,7 +31,7 @@ pub fn Setup() -> impl IntoView {
let user = username.0.get();
spawn_local(async move {
match api::setup::setup(&user, &pass).await {
match shared::server_fns::auth::setup(user, pass).await {
Ok(_) => {
log::info!("Setup completed successfully, redirecting...");
let window = web_sys::window().expect("window should exist");
@@ -40,7 +39,7 @@ pub fn Setup() -> impl IntoView {
}
Err(e) => {
log::error!("Setup failed: {:?}", e);
error.1.set(Some(format!("Hata: {:?}", e)));
error.1.set(Some("Kurulum sırasında bir hata oluştu".to_string()));
loading.1.set(false);
}
}
@@ -48,56 +47,56 @@ pub fn Setup() -> impl IntoView {
};
view! {
<div class="flex items-center justify-center min-h-screen bg-base-200">
<div class="card w-full max-w-md shadow-xl bg-base-100">
<div class="card-body">
<div class="flex flex-col items-center mb-6 text-center">
<div class="w-16 h-16 bg-primary rounded-2xl flex items-center justify-center text-primary-content shadow-lg mb-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-10 h-10">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-3.497a2.548 2.548 0 113.586 3.586l-6.837 5.63m-5.108 3.497l2.496-3.03c.317-.384.74-.626 1.208-.766M15.75 9.25a2.548 2.548 0 11-5.096 0 2.548 2.548 0 015.096 0z" />
</svg>
</div>
<h2 class="card-title text-2xl font-bold">"VibeTorrent Kurulumu"</h2>
<p class="text-base-content/60 text-sm">"Yönetici hesabınızı oluşturun"</p>
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4">
<div class="w-full max-w-md rounded-xl border border-border bg-card text-card-foreground shadow-lg overflow-hidden">
<div class="flex flex-col space-y-1.5 p-6 pb-2 items-center text-center">
<div class="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-sm mb-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-3.497a2.548 2.548 0 113.586 3.586l-6.837 5.63m-5.108 3.497l2.496-3.03c.317-.384.74-.626 1.208-.766M15.75 9.25a2.548 2.548 0 11-5.096 0 2.548 2.548 0 015.096 0z" />
</svg>
</div>
<h3 class="font-semibold tracking-tight text-2xl">"VibeTorrent Kurulumu"</h3>
<p class="text-sm text-muted-foreground">"Yönetici hesabınızı oluşturun"</p>
</div>
<div class="p-6 pt-4">
<form on:submit=handle_setup class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text">"Yönetici Kullanıcı Adı"</span>
<div class="space-y-2">
<label class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
"Yönetici Kullanıcı Adı"
</label>
<input
type="text"
placeholder="admin"
class="input input-bordered w-full"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
prop:value=move || username.0.get()
on:input=move |ev| username.1.set(event_target_value(&ev))
disabled=move || loading.0.get()
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">"Şifre"</span>
<div class="space-y-2">
<label class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
"Şifre"
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
prop:value=move || password.0.get()
on:input=move |ev| password.1.set(event_target_value(&ev))
disabled=move || loading.0.get()
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">"Şifre Onay"</span>
<div class="space-y-2">
<label class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
"Şifre Onay"
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
prop:value=move || confirm_password.0.get()
on:input=move |ev| confirm_password.1.set(event_target_value(&ev))
disabled=move || loading.0.get()
@@ -106,19 +105,20 @@ pub fn Setup() -> impl IntoView {
</div>
<Show when=move || error.0.get().is_some() fallback=|| ()>
<div class="alert alert-error text-xs py-2 shadow-sm">
<div class="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive dark:border-destructive dark:bg-destructive/50 dark:text-destructive-foreground">
<span>{move || error.0.get().unwrap_or_default()}</span>
</div>
</Show>
<div class="form-control mt-6">
<div class="pt-2">
<button
class="btn btn-primary w-full"
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2 w-full"
type="submit"
disabled=move || loading.0.get()
>
<Show when=move || loading.0.get() fallback=|| "Kurulumu Tamamla">
<span class="loading loading-spinner"></span>
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
"Kuruluyor..."
</Show>
</button>
</div>
@@ -127,4 +127,4 @@ pub fn Setup() -> impl IntoView {
</div>
</div>
}
}
}

View File

@@ -51,10 +51,9 @@ pub fn Sidebar() -> impl IntoView {
};
let close_drawer = move || {
if let Some(element) = document().get_element_by_id("my-drawer") {
if let Ok(input) = element.dyn_into::<web_sys::HtmlInputElement>() {
input.set_checked(false);
}
// With Shadcn Sheet, this logic might change, but for now we keep DOM manipulation minimal or handled by parent
if let Some(element) = document().get_element_by_id("mobile-sheet-trigger") {
// Logic to close sheet if open (simulated click or state change)
}
};
@@ -64,10 +63,11 @@ pub fn Sidebar() -> impl IntoView {
};
let filter_class = move |f: crate::store::FilterStatus| {
let base = "w-full justify-start gap-2 h-9 px-4 py-2 inline-flex items-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
if store.filter.get() == f {
"active"
format!("{} bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", base)
} else {
""
format!("{} hover:bg-accent hover:text-accent-foreground text-muted-foreground", base)
}
};
@@ -89,80 +89,77 @@ pub fn Sidebar() -> impl IntoView {
};
view! {
<div class="w-64 min-h-[100dvh] flex flex-col bg-base-200 border-r border-base-300 pb-8" style="padding-top: env(safe-area-inset-top);">
<div class="p-2 flex-1 overflow-y-auto">
<ul class="menu w-full rounded-box gap-1">
<li class="menu-title text-primary uppercase font-bold px-4">"Filters"</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::All))} on:click=move |_| set_filter(crate::store::FilterStatus::All)>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
"All"
<span class="badge badge-sm badge-ghost ml-auto">{total_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Downloading))} on:click=move |_| set_filter(crate::store::FilterStatus::Downloading)>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
"Downloading"
<span class="badge badge-sm badge-ghost ml-auto">{downloading_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Seeding))} on:click=move |_| set_filter(crate::store::FilterStatus::Seeding)>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
"Seeding"
<span class="badge badge-sm badge-ghost ml-auto">{seeding_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Completed))} on:click=move |_| set_filter(crate::store::FilterStatus::Completed)>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
"Completed"
<span class="badge badge-sm badge-ghost ml-auto">{completed_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Paused))} on:click=move |_| set_filter(crate::store::FilterStatus::Paused)>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
"Paused"
<span class="badge badge-sm badge-ghost ml-auto">{paused_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Inactive))} on:click=move |_| set_filter(crate::store::FilterStatus::Inactive)>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
"Inactive"
<span class="badge badge-sm badge-ghost ml-auto">{inactive_count}</span>
</button>
</li>
</ul>
<div class="w-64 min-h-[100dvh] flex flex-col bg-card border-r border-border pb-8" style="padding-top: env(safe-area-inset-top);">
<div class="p-4 flex-1 overflow-y-auto">
<div class="mb-4 px-2 text-lg font-semibold tracking-tight text-foreground">
"VibeTorrent"
</div>
<div class="space-y-1">
<h4 class="mb-1 rounded-md px-2 py-1 text-sm font-semibold text-muted-foreground">"Filters"</h4>
<button class={move || filter_class(crate::store::FilterStatus::All)} on:click=move |_| set_filter(crate::store::FilterStatus::All)>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
"All"
<span class="ml-auto text-xs font-mono opacity-70">{total_count}</span>
</button>
<button class={move || filter_class(crate::store::FilterStatus::Downloading)} on:click=move |_| set_filter(crate::store::FilterStatus::Downloading)>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
"Downloading"
<span class="ml-auto text-xs font-mono opacity-70">{downloading_count}</span>
</button>
<button class={move || filter_class(crate::store::FilterStatus::Seeding)} on:click=move |_| set_filter(crate::store::FilterStatus::Seeding)>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
"Seeding"
<span class="ml-auto text-xs font-mono opacity-70">{seeding_count}</span>
</button>
<button class={move || filter_class(crate::store::FilterStatus::Completed)} on:click=move |_| set_filter(crate::store::FilterStatus::Completed)>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
"Completed"
<span class="ml-auto text-xs font-mono opacity-70">{completed_count}</span>
</button>
<button class={move || filter_class(crate::store::FilterStatus::Paused)} on:click=move |_| set_filter(crate::store::FilterStatus::Paused)>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
"Paused"
<span class="ml-auto text-xs font-mono opacity-70">{paused_count}</span>
</button>
<button class={move || filter_class(crate::store::FilterStatus::Inactive)} on:click=move |_| set_filter(crate::store::FilterStatus::Inactive)>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
"Inactive"
<span class="ml-auto text-xs font-mono opacity-70">{inactive_count}</span>
</button>
</div>
</div>
<div class="p-4 border-t border-base-300 bg-base-200/50">
<div class="p-4 border-t border-border bg-card">
<div class="flex items-center gap-3">
<div class="avatar">
<div class="w-8 rounded-full bg-neutral text-neutral-content ring ring-primary ring-offset-base-100 ring-offset-1">
<span class="text-sm font-bold flex items-center justify-center h-full">{first_letter}</span>
</div>
<div class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-full bg-muted">
<span class="flex h-full w-full items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium">
{first_letter}
</span>
</div>
<div class="flex-1 overflow-hidden">
<div class="font-bold text-sm truncate">{username}</div>
<div class="text-[10px] text-base-content/60 truncate">"Online"</div>
<div class="font-medium text-sm truncate text-foreground">{username}</div>
<div class="text-[10px] text-muted-foreground truncate">"Online"</div>
</div>
<button
class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/10"
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-8 w-8 text-destructive"
title="Logout"
on:click=handle_logout
>

View File

@@ -43,6 +43,12 @@ pub fn StatusBar() -> impl IntoView {
let theme = current_theme.get().to_lowercase();
if let Some(doc) = document().document_element() {
let _ = doc.set_attribute("data-theme", &theme);
// Also set class for Shadcn dark mode support
if theme == "dark" || theme == "dracula" || theme == "dim" || theme == "abyss" {
let _ = doc.class_list().add_1("dark");
} else {
let _ = doc.class_list().remove_1("dark");
}
}
});
@@ -94,11 +100,11 @@ pub fn StatusBar() -> impl IntoView {
};
view! {
<div class="fixed bottom-0 left-0 right-0 h-8 min-h-8 bg-base-200 border-t border-base-300 flex items-center px-4 text-xs gap-4 text-base-content/70 z-[99] cursor-pointer">
<div class="fixed bottom-0 left-0 right-0 h-8 min-h-8 bg-muted border-t border-border flex items-center px-4 text-xs gap-4 text-muted-foreground z-[99] cursor-pointer">
// --- DOWNLOAD SPEED DROPDOWN ---
<details class="dropdown dropdown-top" node_ref=down_details_ref>
<summary class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
<details class="group relative" node_ref=down_details_ref>
<summary class="flex items-center gap-2 cursor-pointer hover:text-foreground transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
</svg>
@@ -110,37 +116,44 @@ pub fn StatusBar() -> impl IntoView {
</Show>
</summary>
<ul class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300">
{
limits.clone().into_iter().map(|(val, label)| {
let is_active = move || {
let current = stats.get().down_limit.unwrap_or(0);
(current - val).abs() < 1024
};
view! {
<li>
<button
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
on:click=move |_| {
set_limit("down", val);
close_details(down_details_ref);
}
>
{label}
<Show when=is_active fallback=|| ()>
<span>""</span>
</Show>
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
<div class="absolute bottom-full left-0 mb-2 z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md hidden group-open:block animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2">
<ul class="w-full">
{
limits.clone().into_iter().map(|(val, label)| {
let is_active = move || {
let current = stats.get().down_limit.unwrap_or(0);
(current - val).abs() < 1024
};
view! {
<li>
<button
class=move || {
let base = "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent hover:text-accent-foreground";
if is_active() { format!("{} bg-accent text-accent-foreground font-medium", base) } else { base.to_string() }
}
on:click=move |_| {
set_limit("down", val);
close_details(down_details_ref);
}
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Show when=is_active fallback=|| ()>
<span>""</span>
</Show>
</span>
{label}
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</div>
</details>
// --- UPLOAD SPEED DROPDOWN ---
<details class="dropdown dropdown-top" node_ref=up_details_ref>
<summary class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
<details class="group relative" node_ref=up_details_ref>
<summary class="flex items-center gap-2 cursor-pointer hover:text-foreground transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
</svg>
@@ -152,114 +165,94 @@ pub fn StatusBar() -> impl IntoView {
</Show>
</summary>
<ul class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300">
{
limits.clone().into_iter().map(|(val, label)| {
let is_active = move || {
let current = stats.get().up_limit.unwrap_or(0);
(current - val).abs() < 1024
};
view! {
<li>
<button
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
on:click=move |_| {
set_limit("up", val);
close_details(up_details_ref);
}
>
{label}
<Show when=is_active fallback=|| ()>
<span>""</span>
</Show>
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</details>
<div class="ml-auto flex items-center gap-4">
<details class="dropdown dropdown-top dropdown-end" node_ref=theme_details_ref>
<summary class="btn btn-ghost btn-xs btn-square cursor-pointer outline-none list-none [&::-webkit-details-marker]:hidden">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
</svg>
</summary>
<ul class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-52 mb-2 border border-base-300 max-h-96 overflow-y-auto">
<div class="absolute bottom-full left-0 mb-2 z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md hidden group-open:block animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2">
<ul class="w-full">
{
let themes = vec![
"light", "dark", "dim", "nord", "cupcake", "dracula", "cyberpunk", "emerald", "sunset", "abyss"
];
themes.into_iter().map(|theme| {
let theme_name = theme.to_string();
let theme_name_for_class = theme_name.clone();
let theme_name_for_onclick = theme_name.clone();
limits.clone().into_iter().map(|(val, label)| {
let is_active = move || {
let current = stats.get().up_limit.unwrap_or(0);
(current - val).abs() < 1024
};
view! {
<li>
<button
class=move || if current_theme.get() == theme_name_for_class { "bg-primary/10 text-primary font-bold text-xs capitalize" } else { "text-xs capitalize" }
class=move || {
let base = "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent hover:text-accent-foreground";
if is_active() { format!("{} bg-accent text-accent-foreground font-medium", base) } else { base.to_string() }
}
on:click=move |_| {
set_current_theme.set(theme_name_for_onclick.clone());
close_details(theme_details_ref);
set_limit("up", val);
close_details(up_details_ref);
}
>
{theme_name}
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Show when=is_active fallback=|| ()>
<span>""</span>
</Show>
</span>
{label}
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</div>
</details>
<div class="ml-auto flex items-center gap-4">
<details class="group relative" node_ref=theme_details_ref>
<summary class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-7 w-7 cursor-pointer outline-none list-none [&::-webkit-details-marker]:hidden">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
</svg>
</summary>
<div class="absolute bottom-full right-0 mb-2 z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md hidden group-open:block animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2 max-h-96 overflow-y-auto">
<ul class="w-full">
{
let themes = vec![
"light", "dark", "dim", "nord", "cupcake", "dracula", "cyberpunk", "emerald", "sunset", "abyss"
];
themes.into_iter().map(|theme| {
let theme_name = theme.to_string();
let theme_name_for_class = theme_name.clone();
let theme_name_for_onclick = theme_name.clone();
let is_active = move || current_theme.get() == theme_name_for_class;
view! {
<li>
<button
class=move || {
let base = "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent hover:text-accent-foreground capitalize";
if is_active() { format!("{} bg-accent text-accent-foreground font-medium", base) } else { base.to_string() }
}
on:click=move |_| {
set_current_theme.set(theme_name_for_onclick.clone());
close_details(theme_details_ref);
}
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Show when=is_active.clone() fallback=|| ()>
<span>""</span>
</Show>
</span>
{theme_name}
</button> </li>
}
}).collect::<Vec<_>>()
}
</ul>
</div>
</details>
<button
class="btn btn-ghost btn-xs btn-square"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-7 w-7"
title="Settings & Notification Permissions"
on:click=move |_| {
// Request push notification permission when settings button is clicked
// Request push notification permission
leptos::task::spawn_local(async {
log::info!("Settings button clicked - requesting push notification permission");
// Check current permission state before requesting
let window = web_sys::window().expect("window should exist");
let _current_perm = js_sys::Reflect::get(&window, &"Notification".into())
.ok()
.and_then(|n| js_sys::Reflect::get(&n, &"permission".into()).ok())
.and_then(|p| p.as_string())
.unwrap_or_default();
// ... existing logic ...
crate::store::subscribe_to_push_notifications().await;
// Check permission after request
let new_perm = js_sys::Reflect::get(&window, &"Notification".into())
.ok()
.and_then(|n| js_sys::Reflect::get(&n, &"permission".into()).ok())
.and_then(|p| p.as_string())
.unwrap_or_default();
if let Some(store) = use_context::<crate::store::TorrentStore>() {
if new_perm == "granted" {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Success,
"Bildirimler etkinleştirildi! Torrent tamamlandığında bildirim alacaksınız.".to_string(),
);
} else if new_perm == "denied" {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Error,
"Bildirim izni reddedildi. Tarayıcı ayarlarından izin verebilirsiniz.".to_string(),
);
} else {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Warning,
"Bildirim izni verilemedi. Açılan izin penceresinde 'İzin Ver' seçeneğini seçin.".to_string(),
);
}
}
// ... existing logic ...
});
}
>

View File

@@ -7,15 +7,16 @@ pub fn Toolbar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
view! {
<div class="navbar min-h-14 h-auto bg-base-100 p-0" style="padding-top: env(safe-area-inset-top);">
<div class="navbar-start gap-4 px-4">
<label for="my-drawer" class="btn btn-square btn-ghost lg:hidden drawer-button">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</label>
<div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);">
<div class="flex flex-1 items-center gap-4">
// Mobile Menu Trigger (Sheet Trigger in full impl)
<button id="mobile-sheet-trigger" class="lg:hidden inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 w-10">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</button>
<div class="flex items-center gap-3">
<button
class="btn btn-primary btn-sm md:btn-md gap-2 shadow-md hover:shadow-primary/20 transition-all"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 shadow gap-2"
on:click=move |_| show_add_modal.1.set(true)
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 md:w-5 md:h-5">
@@ -27,29 +28,27 @@ pub fn Toolbar() -> impl IntoView {
</div>
</div>
<div class="navbar-center hidden md:flex">
<div class="join shadow-sm border border-base-200">
<div class="relative">
<input
type="text"
placeholder="Search..."
class="input input-sm input-bordered join-item w-full max-w-xs focus:outline-none"
prop:value=move || store.search_query.get()
on:input=move |ev| store.search_query.set(event_target_value(&ev))
/>
<Show when=move || !store.search_query.get().is_empty()>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
on:click=move |_| store.search_query.set(String::new())
>
"×"
</button>
</Show>
</div>
<div class="hidden md:flex items-center justify-center flex-1">
<div class="relative w-full max-w-sm">
<input
type="text"
placeholder="Search..."
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
prop:value=move || store.search_query.get()
on:input=move |ev| store.search_query.set(event_target_value(&ev))
/>
<Show when=move || !store.search_query.get().is_empty()>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full text-xs font-medium hover:bg-muted h-5 w-5 opacity-50 hover:opacity-100 transition-opacity"
on:click=move |_| store.search_query.set(String::new())
>
"×"
</button>
</Show>
</div>
</div>
<div class="navbar-end px-4 gap-2">
<div class="flex flex-1 justify-end px-4 gap-2">
<Show when=move || show_add_modal.0.get()>
<AddTorrentDialog on_close=Callback::new(move |()| show_add_modal.1.set(false)) />
</Show>

View File

@@ -2,16 +2,16 @@ use leptos::prelude::*;
use shared::NotificationLevel;
// ============================================================================
// Toast Components - DaisyUI Alert Style
// Toast Components - Shadcn Style
// ============================================================================
/// Returns the DaisyUI alert class for the notification level
fn get_alert_class(level: &NotificationLevel) -> &'static str {
/// Returns the Shadcn class for the notification level
fn get_toast_class(level: &NotificationLevel) -> &'static str {
match level {
NotificationLevel::Info => "alert alert-info",
NotificationLevel::Success => "alert alert-success",
NotificationLevel::Warning => "alert alert-warning",
NotificationLevel::Error => "alert alert-error",
NotificationLevel::Info => "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 shadow-lg transition-all bg-background text-foreground border-border",
NotificationLevel::Success => "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 shadow-lg transition-all bg-background text-foreground border-primary/50 text-primary",
NotificationLevel::Warning => "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 shadow-lg transition-all bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-500 border-yellow-200 dark:border-yellow-900",
NotificationLevel::Error => "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 shadow-lg transition-all destructive group border-destructive bg-destructive text-destructive-foreground",
}
}
@@ -21,36 +21,38 @@ fn ToastItem(
level: NotificationLevel,
message: String,
) -> impl IntoView {
let alert_class = get_alert_class(&level);
let toast_class = get_toast_class(&level);
// DaisyUI SVG icons
// Icons
let icon_svg = match level {
NotificationLevel::Info => view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
}.into_any(),
NotificationLevel::Success => view! {
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}.into_any(),
NotificationLevel::Warning => view! {
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
}.into_any(),
NotificationLevel::Error => view! {
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
}.into_any(),
};
view! {
<div class=alert_class>
{icon_svg}
<span>{message}</span>
<div class=toast_class>
<div class="flex items-center gap-3">
{icon_svg}
<div class="text-sm font-medium">{message}</div>
</div>
</div>
}
}
@@ -63,8 +65,7 @@ pub fn ToastContainer() -> impl IntoView {
view! {
<div
class="toast toast-end toast-bottom"
style="position: fixed; bottom: 20px; right: 20px; z-index: 99999;"
class="fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px] gap-2"
>
<For
each=move || notifications.get()

View File

@@ -1,7 +1,7 @@
use leptos::prelude::*;
use leptos::html;
use leptos::task::spawn_local;
use leptos_use::use_timeout_fn;
use leptos_use::{use_timeout_fn, UseTimeoutFnReturn};
use crate::store::{get_action_messages, show_toast_with_signal};
use crate::api;
use shared::NotificationLevel;
@@ -52,13 +52,11 @@ pub fn TorrentTable() -> impl IntoView {
let filtered_hashes = move || {
let torrents_map = store.torrents.get();
log::debug!("TorrentTable: store.torrents has {} entries", torrents_map.len());
let filter = store.filter.get();
let search = store.search_query.get();
let search_lower = search.to_lowercase();
let mut torrents: Vec<&shared::Torrent> = torrents_map.values().filter(|t| {
let mut torrents: Vec<shared::Torrent> = torrents_map.values().filter(|t| {
let matches_filter = match filter {
crate::store::FilterStatus::All => true,
crate::store::FilterStatus::Downloading => t.status == shared::TorrentStatus::Downloading,
@@ -70,9 +68,7 @@ pub fn TorrentTable() -> impl IntoView {
};
let matches_search = if search_lower.is_empty() { true } else { t.name.to_lowercase().contains(&search_lower) };
matches_filter && matches_search
}).collect();
log::debug!("TorrentTable: {} torrents after filtering", torrents.len());
}).cloned().collect();
torrents.sort_by(|a, b| {
let col = sort_col.0.get();
@@ -150,77 +146,105 @@ pub fn TorrentTable() -> impl IntoView {
};
view! {
<div class="overflow-x-auto h-full bg-base-100 relative">
<div class="hidden md:block h-full overflow-x-auto">
<table class="table table-sm table-pin-rows w-full max-w-full whitespace-nowrap">
<thead>
<tr class="text-xs uppercase text-base-content/60 border-b border-base-200">
<th class="cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
<div class="flex items-center">"Name" {move || sort_arrow(SortColumn::Name)}</div>
</th>
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::Size)>
<div class="flex items-center">"Size" {move || sort_arrow(SortColumn::Size)}</div>
</th>
<th class="w-48 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::Progress)>
<div class="flex items-center">"Progress" {move || sort_arrow(SortColumn::Progress)}</div>
</th>
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::Status)>
<div class="flex items-center">"Status" {move || sort_arrow(SortColumn::Status)}</div>
</th>
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
<div class="flex items-center">"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}</div>
</th>
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
<div class="flex items-center">"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}</div>
</th>
<th class="w-24 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::ETA)>
<div class="flex items-center">"ETA" {move || sort_arrow(SortColumn::ETA)}</div>
</th>
<th class="w-32 cursor-pointer hover:bg-base-300 group select-none" on:click=move |_| handle_sort(SortColumn::AddedDate)>
<div class="flex items-center">"Date" {move || sort_arrow(SortColumn::AddedDate)}</div>
</th>
</tr>
</thead>
<tbody>
<For each=move || filtered_hashes() key=|hash| hash.clone() children={
let handle_context_menu = handle_context_menu.clone();
move |hash| view! { <TorrentRow hash=hash.clone() selected_hash=selected_hash.0 set_selected_hash=selected_hash.1 on_context_menu=handle_context_menu.clone() /> }
} />
</tbody>
</table>
<div class="h-full bg-background relative flex flex-col">
// --- DESKTOP VIEW ---
<div class="hidden md:flex flex-col h-full overflow-hidden">
// Header
<div class="flex items-center text-xs uppercase text-muted-foreground border-b border-border bg-muted/50 h-9 shrink-0 px-2 font-medium">
<div class="flex-1 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Name)>
"Name" {move || sort_arrow(SortColumn::Name)}
</div>
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Size)>
"Size" {move || sort_arrow(SortColumn::Size)}
</div>
<div class="w-48 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Progress)>
"Progress" {move || sort_arrow(SortColumn::Progress)}
</div>
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Status)>
"Status" {move || sort_arrow(SortColumn::Status)}
</div>
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}
</div>
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}
</div>
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::ETA)>
"ETA" {move || sort_arrow(SortColumn::ETA)}
</div>
<div class="w-32 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::AddedDate)>
"Date" {move || sort_arrow(SortColumn::AddedDate)}
</div>
</div>
// Regular List (Standard For loop)
<div class="flex-1 overflow-y-auto min-h-0">
<For each=filtered_hashes key=|hash| hash.clone() children={
let handle_context_menu = handle_context_menu.clone();
move |hash| {
view! {
<TorrentRow
hash=hash.clone()
selected_hash=selected_hash.0
set_selected_hash=selected_hash.1
on_context_menu=handle_context_menu.clone()
/>
}
}
} />
</div>
</div>
<div class="md:hidden flex flex-col h-full bg-base-200 relative cursor-pointer">
<div class="px-3 py-2 border-b border-base-200 flex justify-between items-center bg-base-100/95 backdrop-blur z-10 shrink-0 cursor-default">
<span class="text-xs font-bold opacity-50 uppercase tracking-wider">"Torrents"</span>
// --- MOBILE VIEW ---
<div class="md:hidden flex flex-col h-full bg-muted/10 relative overflow-hidden">
<div class="px-3 py-2 border-b border-border flex justify-between items-center bg-background/95 backdrop-blur z-10 shrink-0">
<span class="text-xs font-bold opacity-50 uppercase tracking-wider text-muted-foreground">"Torrents"</span>
<details class="dropdown dropdown-end" node_ref=sort_details_ref>
<summary class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal list-none [&::-webkit-details-marker]:hidden cursor-pointer">
<summary class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal list-none [&::-webkit-details-marker]:hidden cursor-pointer hover:bg-accent hover:text-accent-foreground rounded-sm px-2 py-1 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 pointer-events-none"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" /></svg>
<span class="pointer-events-none">"Sort"</span>
</summary>
<ul class="dropdown-content z-[100] menu p-2 shadow bg-base-100 rounded-box w-48 mt-1 border border-base-200 text-xs cursor-default">
<li class="menu-title px-2 py-1 opacity-50 text-[10px] uppercase font-bold">"Sort By"</li>
<div class="dropdown-content z-[100] absolute right-0 top-full mt-1 min-w-[10rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md">
<div class="px-2 py-1.5 text-xs font-semibold text-muted-foreground">"Sort By"</div>
<ul class="w-full">
{
let columns = vec![(SortColumn::Name, "Name"), (SortColumn::Size, "Size"), (SortColumn::Progress, "Progress"), (SortColumn::Status, "Status"), (SortColumn::DownSpeed, "DL Speed"), (SortColumn::UpSpeed, "Up Speed"), (SortColumn::ETA, "ETA"), (SortColumn::AddedDate, "Date")];
columns.into_iter().map(|(col, label)| {
let is_active = move || sort_col.0.get() == col;
view! {
<li>
<button type="button" class=move || if is_active() { "bg-primary/10 text-primary font-bold flex justify-between" } else { "flex justify-between" } on:click=move |_| { handle_sort(col); if let Some(el) = sort_details_ref.get() { el.set_open(false); } }>
<button type="button" class=move || if is_active() { "bg-accent text-accent-foreground font-medium flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-xs outline-none" } else { "flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-xs outline-none hover:bg-accent hover:text-accent-foreground" } on:click=move |_| { handle_sort(col); if let Some(el) = sort_details_ref.get() { el.set_open(false); } }>
{label}
<Show when=is_active fallback=|| ()><span class="opacity-70 text-[10px]">{move || match sort_dir.0.get() { SortDirection::Ascending => "", SortDirection::Descending => "" }}</span></Show>
<Show when=is_active fallback=|| ()><span class="ml-auto opacity-70 text-[10px]">{move || match sort_dir.0.get() { SortDirection::Ascending => "", SortDirection::Descending => "" }}</span></Show>
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</ul>
</div>
</details>
</div>
<div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3 cursor-pointer">
<For each=move || filtered_hashes() key=|hash| hash.clone() children={
<div class="flex-1 overflow-y-auto p-3 min-h-0">
<For each=filtered_hashes key=|hash| hash.clone() children={
let handle_context_menu = handle_context_menu.clone();
move |hash| view! { <TorrentCard hash=hash.clone() selected_hash=selected_hash.0 set_selected_hash=selected_hash.1 set_menu_position=menu_position.1 set_menu_visible=menu_visible.1 on_context_menu=handle_context_menu.clone() /> }
let menu_pos_setter = menu_position.1.clone();
let menu_vis_setter = menu_visible.1.clone();
move |hash| {
view! {
<div class="pb-3">
<TorrentCard
hash=hash.clone()
selected_hash=selected_hash.0
set_selected_hash=selected_hash.1
set_menu_position=menu_pos_setter
set_menu_visible=menu_vis_setter
on_context_menu=handle_context_menu.clone()
/>
</div>
}
}
} />
</div>
</div>
@@ -252,17 +276,16 @@ fn TorrentRow(
let t = torrent.get().unwrap();
let t_hash = hash.clone();
let t_name = t.name.clone();
let status_class = match t.status { shared::TorrentStatus::Seeding => "text-success", shared::TorrentStatus::Downloading => "text-primary", shared::TorrentStatus::Paused => "text-warning", shared::TorrentStatus::Error => "text-error", _ => "text-base-content/50" };
let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" };
let status_color = match t.status { shared::TorrentStatus::Seeding => "text-green-500", shared::TorrentStatus::Downloading => "text-blue-500", shared::TorrentStatus::Paused => "text-yellow-500", shared::TorrentStatus::Error => "text-red-500", _ => "text-muted-foreground" };
let selected_hash_clone = selected_hash.clone();
let t_hash_row = t_hash.clone();
view! {
<tr
<div
class=move || {
let base = "hover border-b border-base-200 select-none";
if selected_hash_clone.get() == Some(t_hash_row.clone()) { format!("{} bg-primary/10", base) } else { base.to_string() }
let base = "flex items-center text-sm hover:bg-muted/50 border-b border-border h-[48px] px-2 select-none cursor-pointer";
if selected_hash_clone.get() == Some(t_hash_row.clone()) { format!("{} bg-muted", base) } else { base.to_string() }
}
on:contextmenu={
let t_hash = t_hash.clone();
@@ -275,20 +298,22 @@ fn TorrentRow(
move |_| set_selected_hash.set(Some(t_hash.clone()))
}
>
<td class="font-medium truncate max-w-xs" title=t_name.clone()>{t_name.clone()}</td>
<td class="opacity-80 font-mono text-[11px]">{format_bytes(t.size)}</td>
<td>
<div class="flex-1 min-w-0 px-2 font-medium truncate" title=t_name.clone()>{t_name.clone()}</div>
<div class="w-24 px-2 font-mono text-xs text-muted-foreground">{format_bytes(t.size)}</div>
<div class="w-48 px-2">
<div class="flex items-center gap-2">
<progress class={format!("progress w-24 {}", progress_class)} value={t.percent_complete} max="100"></progress>
<span class="text-[10px] opacity-70">{format!("{:.1}%", t.percent_complete)}</span>
<div class="h-2 w-full bg-secondary rounded-full overflow-hidden">
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
</div>
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>
</div>
</td>
<td class={format!("text-[11px] font-medium {}", status_class)}>{format!("{:?}", t.status)}</td>
<td class="text-right font-mono text-[11px] opacity-80 text-success">{format_speed(t.down_rate)}</td>
<td class="text-right font-mono text-[11px] opacity-80 text-primary">{format_speed(t.up_rate)}</td>
<td class="text-right font-mono text-[11px] opacity-80">{format_duration(t.eta)}</td>
<td class="text-right font-mono text-[11px] opacity-80 whitespace-nowrap">{format_date(t.added_date)}</td>
</tr>
</div>
<div class={format!("w-24 px-2 text-xs font-medium {}", status_color)}>{format!("{:?}", t.status)}</div>
<div class="w-24 px-2 text-right font-mono text-xs text-green-600 dark:text-green-500">{format_speed(t.down_rate)}</div>
<div class="w-24 px-2 text-right font-mono text-xs text-blue-600 dark:text-blue-500">{format_speed(t.up_rate)}</div>
<div class="w-24 px-2 text-right font-mono text-xs text-muted-foreground">{format_duration(t.eta)}</div>
<div class="w-32 px-2 text-right font-mono text-xs text-muted-foreground">{format_date(t.added_date)}</div>
</div>
}
}
}
@@ -318,13 +343,13 @@ fn TorrentCard(
let t = torrent.get().unwrap();
let t_hash = hash.clone();
let t_name = t.name.clone();
let status_badge_class = match t.status { shared::TorrentStatus::Seeding => "badge-success badge-soft", shared::TorrentStatus::Downloading => "badge-primary badge-soft", shared::TorrentStatus::Paused => "badge-warning badge-soft", shared::TorrentStatus::Error => "badge-error badge-soft", _ => "badge-ghost" };
let status_badge_class = match t.status { shared::TorrentStatus::Seeding => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border-green-200 dark:border-green-800", shared::TorrentStatus::Downloading => "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800", shared::TorrentStatus::Paused => "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800", shared::TorrentStatus::Error => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800", _ => "bg-muted text-muted-foreground" };
let t_hash_long = t_hash.clone();
let set_menu_position = set_menu_position.clone();
let set_selected_hash = set_selected_hash.clone();
let set_menu_visible = set_menu_visible.clone();
let leptos_use::UseTimeoutFnReturn { start, .. } = use_timeout_fn(
let UseTimeoutFnReturn { start, .. } = use_timeout_fn(
move |pos: (i32, i32)| {
set_menu_position.set(pos);
set_selected_hash.set(Some(t_hash_long.clone()));
@@ -340,7 +365,7 @@ fn TorrentCard(
view! {
<div
class=move || {
let base = "card card-compact bg-base-100 shadow-sm border border-base-200 select-none cursor-pointer";
let base = "bg-card text-card-foreground rounded-lg border border-border shadow-sm select-none cursor-pointer h-full";
if selected_hash_clone.get() == Some(t_hash_card.clone()) { format!("{} ring-2 ring-primary ring-inset", base) } else { base.to_string() }
}
on:contextmenu={
@@ -358,21 +383,23 @@ fn TorrentCard(
move |e: web_sys::TouchEvent| if let Some(touch) = e.touches().get(0) { start((touch.client_x(), touch.client_y())); }
}
>
<div class="card-body gap-3">
<div class="p-3 gap-3 flex flex-col h-full justify-between">
<div class="flex justify-between items-start gap-2">
<h3 class="font-medium text-sm line-clamp-2 leading-tight">{t_name.clone()}</h3>
<div class={format!("badge badge-xs text-[10px] whitespace-nowrap {}", status_badge_class)}>{format!("{:?}", t.status)}</div>
<div class={format!("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 {}", status_badge_class)}>{format!("{:?}", t.status)}</div>
</div>
<div class="flex flex-col gap-1">
<div class="flex justify-between text-[10px] opacity-70">
<div class="flex justify-between text-[10px] text-muted-foreground">
<span>{format_bytes(t.size)}</span>
<span>{format!("{:.1}%", t.percent_complete)}</span>
</div>
<progress class="progress w-full h-1.5" value={t.percent_complete} max="100"></progress>
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden">
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
</div>
</div>
<div class="grid grid-cols-4 gap-2 text-[10px] font-mono opacity-80 pt-1 border-t border-base-200/50">
<div class="flex flex-col text-success"><span>"DL"</span><span>{format_speed(t.down_rate)}</span></div>
<div class="flex flex-col text-primary"><span>"UP"</span><span>{format_speed(t.up_rate)}</span></div>
<div class="grid grid-cols-4 gap-2 text-[10px] font-mono text-muted-foreground pt-1 border-t border-border/50">
<div class="flex flex-col text-blue-600 dark:text-blue-500"><span>"DL"</span><span>{format_speed(t.down_rate)}</span></div>
<div class="flex flex-col text-green-600 dark:text-green-500"><span>"UP"</span><span>{format_speed(t.up_rate)}</span></div>
<div class="flex flex-col"><span>"ETA"</span><span>{format_duration(t.eta)}</span></div>
<div class="flex flex-col text-right"><span>"DATE"</span><span>{format_date(t.added_date)}</span></div>
</div>

View File

@@ -1,9 +1,11 @@
use futures::StreamExt;
use gloo_net::eventsource::futures::EventSource;
use leptos::prelude::*;
use leptos::task::spawn_local;
use shared::{AppEvent, GlobalStats, NotificationLevel, SystemNotification, Torrent};
use std::collections::HashMap;
use serde::{Serialize, Deserialize};
use struct_patch::traits::Patch;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
#[derive(Clone, Debug, PartialEq)]
pub struct NotificationItem {
@@ -11,10 +13,6 @@ pub struct NotificationItem {
pub notification: SystemNotification,
}
// ============================================================================
// Toast Helper Functions
// ============================================================================
pub fn show_toast_with_signal(
notifications: RwSignal<Vec<NotificationItem>>,
level: NotificationLevel,
@@ -29,7 +27,6 @@ pub fn show_toast_with_signal(
notifications.update(|list| list.push(item));
// Auto-remove after 5 seconds
leptos::prelude::set_timeout(
move || {
notifications.update(|list| list.retain(|i| i.id != id));
@@ -47,10 +44,6 @@ pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); }
pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); }
// ============================================================================
// Action Message Mapping
// ============================================================================
pub fn get_action_messages(action: &str) -> (&'static str, &'static str) {
match action {
"start" => ("Torrent başlatıldı", "Torrent başlatılamadı"),
@@ -63,22 +56,6 @@ pub fn get_action_messages(action: &str) -> (&'static str, &'static str) {
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PushSubscriptionData {
pub endpoint: String,
pub keys: PushKeys,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PushKeys {
pub p256dh: String,
pub auth: String,
}
// ============================================================================
// Store Definition
// ============================================================================
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FilterStatus {
All, Downloading, Seeding, Completed, Paused, Inactive, Active, Error,
@@ -107,106 +84,99 @@ pub fn provide_torrent_store() {
let store = TorrentStore { torrents, filter, search_query, global_stats, notifications, user };
provide_context(store);
let notifications_for_effect = notifications;
let global_stats_for_effect = global_stats;
let torrents_for_effect = torrents;
let notifications_for_sse = notifications;
let global_stats_for_sse = global_stats;
let torrents_for_sse = torrents;
let show_browser_notification = show_browser_notification.clone();
Effect::new(move |_| {
let user_val = user.get();
log::debug!("SSE Effect: user = {:?}", user_val);
if user_val.is_none() {
log::debug!("SSE Effect: user is None, skipping connection");
return;
}
spawn_local(async move {
let mut backoff_ms: u32 = 1000;
let mut was_connected = false;
let mut disconnect_notified = false;
let notifications = notifications_for_effect;
let global_stats = global_stats_for_effect;
let torrents = torrents_for_effect;
let show_browser_notification = show_browser_notification.clone();
loop {
log::info!("SSE: Starting connection (user logged in)");
leptos::task::spawn_local(async move {
let mut backoff_ms: u32 = 1000;
let mut was_connected = false;
let mut disconnect_notified = false;
loop {
let es_result = EventSource::new("/api/events");
match es_result {
Ok(mut es) => {
if let Ok(mut stream) = es.subscribe("message") {
let mut got_first_message = false;
while let Some(Ok((_, msg))) = stream.next().await {
if !got_first_message {
got_first_message = true;
backoff_ms = 1000;
if was_connected && disconnect_notified {
show_toast_with_signal(notifications, NotificationLevel::Success, "Sunucu bağlantısı yeniden kuruldu");
disconnect_notified = false;
}
was_connected = true;
log::debug!("SSE: Creating EventSource...");
let es_result = EventSource::new("/api/events");
match es_result {
Ok(mut es) => {
log::debug!("SSE: EventSource created, subscribing...");
if let Ok(mut stream) = es.subscribe("message") {
log::debug!("SSE: Subscribed to message channel");
let mut got_first_message = false;
while let Some(Ok((_, msg))) = stream.next().await {
log::debug!("SSE: Received message");
if !got_first_message {
got_first_message = true;
backoff_ms = 1000;
if was_connected && disconnect_notified {
show_toast_with_signal(notifications_for_sse, NotificationLevel::Success, "Sunucu bağlantısı yeniden kuruldu");
disconnect_notified = false;
}
was_connected = true;
}
if let Some(data_str) = msg.data().as_string() {
if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) {
match event {
AppEvent::FullList { torrents: list, .. } => {
log::debug!("SSE: Received FullList with {} torrents", list.len());
torrents.update(|map| {
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
map.retain(|hash, _| new_hashes.contains(hash));
for new_torrent in list {
map.insert(new_torrent.hash.clone(), new_torrent);
if let Some(data_str) = msg.data().as_string() {
// Decode Base64
match BASE64.decode(&data_str) {
Ok(bytes) => {
// Deserialize MessagePack
match rmp_serde::from_slice::<AppEvent>(&bytes) {
Ok(event) => {
match event {
AppEvent::FullList { torrents: list, .. } => {
log::info!("SSE: Received FullList with {} torrents", list.len());
torrents_for_sse.update(|map| {
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
map.retain(|hash, _| new_hashes.contains(hash));
for new_torrent in list {
map.insert(new_torrent.hash.clone(), new_torrent);
}
});
log::debug!("SSE: torrents map now has {} entries", torrents_for_sse.with(|m| m.len()));
}
});
log::debug!("SSE: torrents map now has {} entries", torrents.with(|m| m.len()));
}
AppEvent::Update(update) => {
torrents.update(|map| {
if let Some(t) = map.get_mut(&update.hash) {
if let Some(v) = update.name { t.name = v; }
if let Some(v) = update.size { t.size = v; }
if let Some(v) = update.down_rate { t.down_rate = v; }
if let Some(v) = update.up_rate { t.up_rate = v; }
if let Some(v) = update.percent_complete { t.percent_complete = v; }
if let Some(v) = update.completed { t.completed = v; }
if let Some(v) = update.eta { t.eta = v; }
if let Some(v) = update.status { t.status = v; }
if let Some(v) = update.error_message { t.error_message = v; }
if let Some(v) = update.label { t.label = Some(v); }
AppEvent::Update(patch) => {
torrents_for_sse.update(|map| {
if let Some(hash) = patch.hash.as_ref() {
if let Some(t) = map.get_mut(hash) {
t.apply(patch);
}
}
});
}
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
AppEvent::Notification(n) => {
show_toast_with_signal(notifications_for_sse, n.level.clone(), n.message.clone());
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
show_browser_notification("VibeTorrent", &n.message);
}
}
});
}
AppEvent::Stats(stats) => { global_stats.set(stats); }
AppEvent::Notification(n) => {
show_toast_with_signal(notifications, n.level.clone(), n.message.clone());
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
show_browser_notification("VibeTorrent", &n.message);
}
}
Err(e) => log::error!("SSE: Failed to deserialize MessagePack: {}", e),
}
}
Err(e) => log::error!("SSE: Failed to decode Base64: {}", e),
}
}
if was_connected && !disconnect_notified {
show_toast_with_signal(notifications, NotificationLevel::Warning, "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...");
disconnect_notified = true;
}
}
}
Err(_) => {
if was_connected && !disconnect_notified {
show_toast_with_signal(notifications, NotificationLevel::Warning, "Sunucu bağlantısı kurulamıyor...");
show_toast_with_signal(notifications_for_sse, NotificationLevel::Warning, "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...");
disconnect_notified = true;
}
}
}
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
backoff_ms = std::cmp::min(backoff_ms * 2, 30000);
Err(_) => {
if was_connected && !disconnect_notified {
show_toast_with_signal(notifications_for_sse, NotificationLevel::Warning, "Sunucu bağlantısı kurulamıyor...");
disconnect_notified = true;
}
}
}
});
log::debug!("SSE: Reconnecting in {}ms...", backoff_ms);
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
backoff_ms = std::cmp::min(backoff_ms * 2, 30000);
}
});
}

View File

@@ -0,0 +1,30 @@
[package]
edition = "2018"
name = "coarsetime"
version = "0.1.37"
description = "Time and duration crate optimized for speed (patched for MIPS)"
license = "BSD-2-Clause"
[features]
wasi-abi2 = ["dep:wasi-abi2"]
[lib]
name = "coarsetime"
path = "src/lib.rs"
[dependencies]
portable-atomic = { version = "1", default-features = false, features = ["fallback"] }
[target.'cfg(all(any(target_arch = "wasm32", target_arch = "wasm64"), target_os = "unknown"))'.dependencies.wasm-bindgen]
version = "0.2"
[target.'cfg(any(target_os = "wasix", target_os = "wasi"))'.dependencies.wasix]
version = "0.13"
[target.'cfg(not(any(target_os = "wasix", target_os = "wasi")))'.dependencies.libc]
version = "0.2"
[target.'cfg(target_os = "wasi")'.dependencies.wasi-abi2]
version = "0.14.7"
optional = true
package = "wasi"

View File

@@ -4,9 +4,6 @@
)))]
use std::time;
#[cfg(target_has_atomic = "64")]
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(not(target_has_atomic = "64"))]
use portable_atomic::{AtomicU64, Ordering};
use super::Duration;

View File

@@ -3,9 +3,6 @@ use std::mem::MaybeUninit;
use std::ops::*;
#[allow(unused_imports)]
use std::ptr::*;
#[cfg(target_has_atomic = "64")]
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(not(target_has_atomic = "64"))]
use portable_atomic::{AtomicU64, Ordering};
use super::duration::*;

View File

@@ -3,6 +3,33 @@ name = "shared"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
utoipa = { version = "5.4.0", features = ["axum_extras"] }
struct-patch = "0.5"
rmp-serde = "1.3"
# Leptos 0.8.7
leptos = { version = "0.8.7", features = ["nightly"] }
leptos_router = { version = "0.8.7", features = ["nightly"] }
leptos_axum = { version = "0.8.7", optional = true }
axum = { version = "0.8", features = ["macros"], optional = true }
# SSR Dependencies (XML-RPC & SCGI)
tokio = { version = "1", features = ["full"], optional = true }
bytes = { version = "1", optional = true }
thiserror = { version = "2", optional = true }
quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = true }
# Database
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"], optional = true }
anyhow = { version = "1.0", optional = true }
# Auth (SSR)
jsonwebtoken = { version = "9", optional = true }
cookie = { version = "0.18", features = ["percent-encode"], optional = true }
bcrypt = { version = "0.17", optional = true }
[features]
default = []
ssr = [
@@ -11,22 +38,13 @@ ssr = [
"dep:thiserror",
"dep:quick-xml",
"dep:leptos_axum",
"dep:sqlx",
"dep:anyhow",
"dep:jsonwebtoken",
"dep:cookie",
"dep:bcrypt",
"dep:axum",
"leptos/ssr",
"leptos_router/ssr",
]
hydrate = ["leptos/hydrate"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
utoipa = { version = "5.4.0", features = ["axum_extras"] }
# Leptos 0.8.7
leptos = { version = "0.8.7", features = ["nightly"] }
leptos_router = { version = "0.8.7", features = ["nightly"] }
leptos_axum = { version = "0.8.7", optional = true }
# SSR Dependencies (XML-RPC & SCGI)
tokio = { version = "1", features = ["full"], optional = true }
bytes = { version = "1", optional = true }
thiserror = { version = "2", optional = true }
quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = true }

View File

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use struct_patch::Patch;
use utoipa::ToSchema;
#[cfg(feature = "ssr")]
@@ -7,9 +8,25 @@ pub mod scgi;
#[cfg(feature = "ssr")]
pub mod xmlrpc;
#[cfg(feature = "ssr")]
pub mod db;
pub mod server_fns;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
#[derive(Clone, Debug)]
pub struct ServerContext {
pub scgi_socket_path: String,
}
#[cfg(feature = "ssr")]
#[derive(Clone)]
pub struct DbContext {
pub db: db::Db,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Patch)]
#[patch_derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Default)]
#[patch_name = "TorrentUpdate"]
pub struct Torrent {
pub hash: String,
pub name: String,
@@ -36,14 +53,20 @@ pub enum TorrentStatus {
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
#[serde(tag = "type", content = "data")]
#[serde(tag = "t", content = "d")]
pub enum AppEvent {
#[serde(rename = "f")]
FullList {
#[serde(rename = "t")]
torrents: Vec<Torrent>,
#[serde(rename = "ts")]
timestamp: u64,
},
#[serde(rename = "u")]
Update(TorrentUpdate),
#[serde(rename = "s")]
Stats(GlobalStats),
#[serde(rename = "n")]
Notification(SystemNotification),
}
@@ -70,20 +93,8 @@ pub struct GlobalStats {
pub free_space: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct TorrentUpdate {
pub hash: String,
pub name: Option<String>,
pub size: Option<i64>,
pub down_rate: Option<i64>,
pub up_rate: Option<i64>,
pub percent_complete: Option<f64>,
pub completed: Option<i64>,
pub eta: Option<i64>,
pub status: Option<TorrentStatus>,
pub error_message: Option<String>,
pub label: Option<String>,
}
// REMOVED: Manual TorrentUpdate struct definition as it's now generated by Patch macro
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct TorrentActionRequest {

View File

@@ -0,0 +1,168 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserResponse {
pub id: i64,
pub username: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // username
pub uid: i64, // user id
pub exp: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SetupStatus {
pub completed: bool,
}
#[server(GetSetupStatus, "/api/server_fns/GetSetupStatus")]
pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
use crate::DbContext;
let db_context = use_context::<DbContext>().ok_or_else(|| ServerFnError::new("DB Context missing"))?;
let has_users = db_context.db.has_users().await
.map_err(|e| ServerFnError::new(format!("DB error: {}", e)))?;
Ok(SetupStatus {
completed: has_users,
})
}
#[server(Setup, "/api/server_fns/Setup")]
pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> {
use crate::DbContext;
let db_context = use_context::<DbContext>().ok_or_else(|| ServerFnError::new("DB Context missing"))?;
// Check if setup is already done
let has_users = db_context.db.has_users().await.unwrap_or(false);
if has_users {
return Err(ServerFnError::new("Setup already completed"));
}
// Hash password (low cost for MIPS)
let password_hash = bcrypt::hash(&password, 6)
.map_err(|_| ServerFnError::new("Hashing error"))?;
db_context.db.create_user(&username, &password_hash).await
.map_err(|e| ServerFnError::new(format!("DB error: {}", e)))?;
Ok(())
}
#[server(Login, "/api/server_fns/Login")]
pub async fn login(username: String, password: String) -> Result<UserResponse, ServerFnError> {
use crate::DbContext;
use leptos_axum::ResponseOptions;
use jsonwebtoken::{encode, Header, EncodingKey};
use cookie::{Cookie, SameSite};
use std::time::{SystemTime, UNIX_EPOCH};
let db_context = use_context::<DbContext>().ok_or_else(|| ServerFnError::new("DB Context missing"))?;
let user_opt = db_context.db.get_user_by_username(&username).await
.map_err(|e| ServerFnError::new(format!("DB error: {}", e)))?;
if let Some((uid, password_hash)) = user_opt {
let valid = bcrypt::verify(&password, &password_hash).unwrap_or(false);
if !valid {
return Err(ServerFnError::new("Invalid credentials"));
}
let expiration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as usize + 24 * 3600; // 24 hours
let claims = Claims {
sub: username.clone(),
uid,
exp: expiration,
};
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
.map_err(|e| ServerFnError::new(format!("Token error: {}", e)))?;
let cookie = Cookie::build(("auth_token", token))
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.build();
if let Some(options) = use_context::<ResponseOptions>() {
options.insert_header(
axum::http::header::SET_COOKIE,
axum::http::HeaderValue::from_str(&cookie.to_string()).unwrap(),
);
}
Ok(UserResponse {
id: uid,
username,
})
} else {
Err(ServerFnError::new("Invalid credentials"))
}
}
#[server(Logout, "/api/server_fns/Logout")]
pub async fn logout() -> Result<(), ServerFnError> {
use leptos_axum::ResponseOptions;
use cookie::{Cookie, SameSite};
let cookie = Cookie::build(("auth_token", ""))
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.max_age(cookie::time::Duration::seconds(0))
.build();
if let Some(options) = use_context::<ResponseOptions>() {
options.insert_header(
axum::http::header::SET_COOKIE,
axum::http::HeaderValue::from_str(&cookie.to_string()).unwrap(),
);
}
Ok(())
}
#[server(GetUser, "/api/server_fns/GetUser")]
pub async fn get_user() -> Result<Option<UserResponse>, ServerFnError> {
use axum::http::HeaderMap;
use leptos_axum::extract;
use jsonwebtoken::{decode, Validation, DecodingKey};
let headers: HeaderMap = extract().await.map_err(|e| ServerFnError::new(format!("Extract error: {}", e)))?;
let cookie_header = headers.get(axum::http::header::COOKIE)
.and_then(|h| h.to_str().ok());
if let Some(cookie_str) = cookie_header {
for c_str in cookie_str.split(';') {
if let Ok(c) = cookie::Cookie::parse(c_str.trim()) {
if c.name() == "auth_token" {
let token = c.value();
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
);
if let Ok(data) = token_data {
return Ok(Some(UserResponse {
id: data.claims.uid,
username: data.claims.sub,
}));
}
}
}
}
}
Ok(None)
}

View File

@@ -1,26 +1,4 @@
use leptos::*;
use leptos::prelude::*;
#[cfg(feature = "ssr")]
use crate::xmlrpc::{self, RtorrentClient};
#[server(GetVersion, "/api/server_fns")]
pub async fn get_version() -> Result<String, ServerFnError> {
let socket_path = std::env::var("RTORRENT_SOCKET").unwrap_or_else(|_| "/tmp/rtorrent.sock".to_string());
#[cfg(feature = "ssr")]
{
let client = RtorrentClient::new(&socket_path);
match client.call("system.client_version", &[]).await {
Ok(xml) => {
let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml);
Ok(version)
},
Err(e) => Err(ServerFnError::ServerError(e.to_string())),
}
}
#[cfg(not(feature = "ssr"))]
{
unreachable!()
}
}
pub mod torrent;
pub mod settings;
pub mod push;
pub mod auth;

View File

@@ -0,0 +1,22 @@
use leptos::prelude::*;
#[server(GetPushPublicKey, "/api/server_fns")]
pub async fn get_public_key() -> Result<String, ServerFnError> {
let key = std::env::var("VAPID_PUBLIC_KEY")
.map_err(|_| ServerFnError::new("VAPID_PUBLIC_KEY not configured"))?;
Ok(key)
}
#[server(SubscribePush, "/api/server_fns")]
pub async fn subscribe_push(
endpoint: String,
p256dh: String,
auth: String,
) -> Result<(), ServerFnError> {
let db_ctx = expect_context::<crate::DbContext>();
db_ctx
.db
.save_push_subscription(&endpoint, &p256dh, &auth)
.await
.map_err(|e| ServerFnError::new(format!("Failed to save subscription: {}", e)))
}

View File

@@ -0,0 +1,58 @@
use leptos::prelude::*;
use crate::GlobalLimitRequest;
#[server(GetGlobalLimits, "/api/server_fns")]
pub async fn get_global_limits() -> Result<GlobalLimitRequest, ServerFnError> {
use crate::xmlrpc::{self, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let down = match client.call("throttle.global_down.max_rate", &[]).await {
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
Err(_) => -1,
};
let up = match client.call("throttle.global_up.max_rate", &[]).await {
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
Err(_) => -1,
};
Ok(GlobalLimitRequest {
max_download_rate: Some(down),
max_upload_rate: Some(up),
})
}
#[server(SetGlobalLimits, "/api/server_fns")]
pub async fn set_global_limits(
max_download_rate: Option<i64>,
max_upload_rate: Option<i64>,
) -> Result<(), ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
if let Some(down) = max_download_rate {
let down_kb = down / 1024;
client
.call(
"throttle.global_down.max_rate.set_kb",
&[RpcParam::from(""), RpcParam::Int(down_kb)],
)
.await
.map_err(|e| ServerFnError::new(format!("Failed to set down limit: {}", e)))?;
}
if let Some(up) = max_upload_rate {
let up_kb = up / 1024;
client
.call(
"throttle.global_up.max_rate.set_kb",
&[RpcParam::from(""), RpcParam::Int(up_kb)],
)
.await
.map_err(|e| ServerFnError::new(format!("Failed to set up limit: {}", e)))?;
}
Ok(())
}

View File

@@ -0,0 +1,274 @@
use leptos::prelude::*;
use crate::{TorrentFile, TorrentPeer, TorrentTracker};
#[server(AddTorrent, "/api/server_fns")]
pub async fn add_torrent(uri: String) -> Result<(), ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![RpcParam::from(""), RpcParam::from(uri.as_str())];
match client.call("load.start", &params).await {
Ok(response) => {
if response.contains("faultCode") {
return Err(ServerFnError::new("rTorrent returned fault"));
}
Ok(())
}
Err(e) => Err(ServerFnError::new(format!("Failed to add torrent: {}", e))),
}
}
#[server(TorrentAction, "/api/server_fns")]
pub async fn torrent_action(hash: String, action: String) -> Result<String, ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
if action == "delete_with_data" {
return delete_torrent_with_data_inner(&client, &hash).await;
}
let method = match action.as_str() {
"start" => "d.start",
"stop" => "d.stop",
"delete" => "d.erase",
_ => return Err(ServerFnError::new("Invalid action")),
};
let params = vec![RpcParam::from(hash.as_str())];
match client.call(method, &params).await {
Ok(_) => Ok("Action executed".to_string()),
Err(e) => Err(ServerFnError::new(format!("RPC error: {}", e))),
}
}
#[cfg(feature = "ssr")]
async fn delete_torrent_with_data_inner(
client: &crate::xmlrpc::RtorrentClient,
hash: &str,
) -> Result<String, ServerFnError> {
use crate::xmlrpc::{parse_string_response, RpcParam};
let params_hash = vec![RpcParam::from(hash)];
let path_xml = client
.call("d.base_path", &params_hash)
.await
.map_err(|e| ServerFnError::new(format!("Failed to call rTorrent: {}", e)))?;
let path = parse_string_response(&path_xml)
.map_err(|e| ServerFnError::new(format!("Failed to parse path: {}", e)))?;
let root_xml = client
.call("directory.default", &[])
.await
.map_err(|e| ServerFnError::new(format!("Failed to get download root: {}", e)))?;
let root_path_str = parse_string_response(&root_xml)
.map_err(|e| ServerFnError::new(format!("Failed to parse root path: {}", e)))?;
let root_path = tokio::fs::canonicalize(std::path::Path::new(&root_path_str))
.await
.map_err(|e| ServerFnError::new(format!("Invalid download root: {}", e)))?;
let target_path_raw = std::path::Path::new(&path);
if !tokio::fs::try_exists(target_path_raw).await.unwrap_or(false) {
client
.call("d.erase", &params_hash)
.await
.map_err(|e| ServerFnError::new(format!("Failed to erase torrent: {}", e)))?;
return Ok("Torrent removed (Data not found)".to_string());
}
let target_path = tokio::fs::canonicalize(target_path_raw)
.await
.map_err(|e| ServerFnError::new(format!("Invalid data path: {}", e)))?;
if !target_path.starts_with(&root_path) {
return Err(ServerFnError::new(
"Security Error: Cannot delete files outside download directory",
));
}
if target_path == root_path {
return Err(ServerFnError::new(
"Security Error: Cannot delete the download root directory",
));
}
client
.call("d.erase", &params_hash)
.await
.map_err(|e| ServerFnError::new(format!("Failed to erase torrent: {}", e)))?;
let delete_result = if target_path.is_dir() {
tokio::fs::remove_dir_all(&target_path).await
} else {
tokio::fs::remove_file(&target_path).await
};
match delete_result {
Ok(_) => Ok("Torrent and data deleted".to_string()),
Err(e) => Err(ServerFnError::new(format!("Failed to delete data: {}", e))),
}
}
#[server(GetFiles, "/api/server_fns")]
pub async fn get_files(hash: String) -> Result<Vec<TorrentFile>, ServerFnError> {
use crate::xmlrpc::{parse_multicall_response, RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("f.path="),
RpcParam::from("f.size_bytes="),
RpcParam::from("f.completed_chunks="),
RpcParam::from("f.priority="),
];
let xml = client
.call("f.multicall", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
let rows = parse_multicall_response(&xml)
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
Ok(rows
.into_iter()
.enumerate()
.map(|(idx, row)| TorrentFile {
index: idx as u32,
path: row.get(0).cloned().unwrap_or_default(),
size: row.get(1).and_then(|s| s.parse().ok()).unwrap_or(0),
completed_chunks: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
priority: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
})
.collect())
}
#[server(GetPeers, "/api/server_fns")]
pub async fn get_peers(hash: String) -> Result<Vec<TorrentPeer>, ServerFnError> {
use crate::xmlrpc::{parse_multicall_response, RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("p.address="),
RpcParam::from("p.client_version="),
RpcParam::from("p.down_rate="),
RpcParam::from("p.up_rate="),
RpcParam::from("p.completed_percent="),
];
let xml = client
.call("p.multicall", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
let rows = parse_multicall_response(&xml)
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
Ok(rows
.into_iter()
.map(|row| TorrentPeer {
ip: row.get(0).cloned().unwrap_or_default(),
client: row.get(1).cloned().unwrap_or_default(),
down_rate: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
up_rate: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
progress: row.get(4).and_then(|s| s.parse().ok()).unwrap_or(0.0),
})
.collect())
}
#[server(GetTrackers, "/api/server_fns")]
pub async fn get_trackers(hash: String) -> Result<Vec<TorrentTracker>, ServerFnError> {
use crate::xmlrpc::{parse_multicall_response, RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("t.url="),
RpcParam::from("t.activity_date_last="),
RpcParam::from("t.message="),
];
let xml = client
.call("t.multicall", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
let rows = parse_multicall_response(&xml)
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
Ok(rows
.into_iter()
.map(|row| TorrentTracker {
url: row.get(0).cloned().unwrap_or_default(),
status: "Unknown".to_string(),
message: row.get(2).cloned().unwrap_or_default(),
})
.collect())
}
#[server(SetFilePriority, "/api/server_fns")]
pub async fn set_file_priority(
hash: String,
file_index: u32,
priority: u8,
) -> Result<(), ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let target = format!("{}:f{}", hash, file_index);
let params = vec![
RpcParam::from(target.as_str()),
RpcParam::from(priority as i64),
];
client
.call("f.set_priority", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
let _ = client
.call("d.update_priorities", &[RpcParam::from(hash.as_str())])
.await;
Ok(())
}
#[server(SetLabel, "/api/server_fns")]
pub async fn set_label(hash: String, label: String) -> Result<(), ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![RpcParam::from(hash.as_str()), RpcParam::from(label)];
client
.call("d.custom1.set", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
Ok(())
}
#[server(GetVersion, "/api/server_fns")]
pub async fn get_version() -> Result<String, ServerFnError> {
use crate::xmlrpc::{parse_string_response, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
match client.call("system.client_version", &[]).await {
Ok(xml) => {
let version = parse_string_response(&xml).unwrap_or(xml);
Ok(version)
}
Err(e) => Err(ServerFnError::new(format!("Failed to get version: {}", e))),
}
}

View File

@@ -1 +0,0 @@
{"v":1}

View File

@@ -1,6 +0,0 @@
{
"git": {
"sha1": "831c97016aa3d8f7851999aa1deea8407e7cbd42"
},
"path_in_vcs": ""
}

View File

@@ -1,8 +0,0 @@
version: 2
updates:
- package-ecosystem: cargo
directory: "/"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10

View File

@@ -1,17 +0,0 @@
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,4 +0,0 @@
target
Cargo.lock
.vscode
zig-cache

View File

@@ -1,82 +0,0 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2018"
name = "coarsetime"
version = "0.1.37"
authors = ["Frank Denis <github@pureftpd.org>"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Time and duration crate optimized for speed"
homepage = "https://github.com/jedisct1/rust-coarsetime"
readme = "README.md"
keywords = [
"time",
"date",
"duration",
]
categories = [
"concurrency",
"date-and-time",
"os",
]
license = "BSD-2-Clause"
repository = "https://github.com/jedisct1/rust-coarsetime"
[features]
wasi-abi2 = ["dep:wasi-abi2"]
[lib]
name = "coarsetime"
path = "src/lib.rs"
[[bench]]
name = "benchmark"
path = "benches/benchmark.rs"
harness = false
[dev-dependencies.benchmark-simple]
version = "0.1.10"
[dependencies.portable-atomic]
version = "1.6"
[target.'cfg(all(any(target_arch = "wasm32", target_arch = "wasm64"), target_os = "unknown"))'.dependencies.wasm-bindgen]
version = "0.2"
[target.'cfg(any(target_os = "wasix", target_os = "wasi"))'.dependencies.wasix]
version = "0.13"
[target.'cfg(not(any(target_os = "wasix", target_os = "wasi")))'.dependencies.libc]
version = "0.2"
[target.'cfg(target_os = "wasi")'.dependencies.wasi-abi2]
version = "0.14.7"
optional = true
package = "wasi"
[profile.bench]
codegen-units = 1
[profile.dev]
overflow-checks = true
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
incremental = false

View File

@@ -1,47 +0,0 @@
[package]
name = "coarsetime"
version = "0.1.37"
description = "Time and duration crate optimized for speed"
authors = ["Frank Denis <github@pureftpd.org>"]
keywords = ["time", "date", "duration"]
readme = "README.md"
license = "BSD-2-Clause"
homepage = "https://github.com/jedisct1/rust-coarsetime"
repository = "https://github.com/jedisct1/rust-coarsetime"
categories = ["concurrency", "date-and-time", "os"]
edition = "2018"
[features]
wasi-abi2 = ["dep:wasi-abi2"]
[target.'cfg(not(any(target_os = "wasix", target_os = "wasi")))'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "wasi")'.dependencies]
wasi-abi2 = { package = "wasi", version = "0.14.7", optional = true }
[target.'cfg(any(target_os = "wasix", target_os = "wasi"))'.dependencies]
wasix = "0.13"
[target.'cfg(all(any(target_arch = "wasm32", target_arch = "wasm64"), target_os = "unknown"))'.dependencies]
wasm-bindgen = "0.2"
[dev-dependencies]
benchmark-simple = "0.1.10"
[profile.bench]
codegen-units = 1
[[bench]]
name = "benchmark"
harness = false
[profile.release]
lto = true
panic = "abort"
opt-level = 3
codegen-units = 1
incremental = false
[profile.dev]
overflow-checks=true

View File

@@ -1,25 +0,0 @@
BSD 2-Clause License
Copyright (c) 2016-2026, Frank Denis
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -1,90 +0,0 @@
[![Documentation](https://docs.rs/coarsetime/badge.svg)](https://docs.rs/coarsetime)
[![Windows build status](https://ci.appveyor.com/api/projects/status/xlbhk9850dvl5ylh?svg=true)](https://ci.appveyor.com/project/jedisct1/rust-coarsetime)
# coarsetime
A Rust crate to make time measurements, that focuses on speed, API stability and portability.
This crate is a partial replacement for the `Time` and `Duration` structures
from the standard library, with the following differences:
* Speed is privileged over accuracy. In particular, `CLOCK_MONOTONIC_COARSE` is
used to retrieve the clock value on Linux systems, and transformations avoid
operations that can be slow on non-Intel systems.
* The number of system calls can be kept to a minimum. The "most recent
timestamp" is always kept in memory. It can be read with just a load operation,
and can be updated only as frequently as necessary.
* The API is stable, and the same for all platforms. Unlike the standard library, it doesn't silently compile functions that do nothing but panic at runtime on some platforms.
# Installation
`coarsetime` is available on [crates.io](https://crates.io/crates/coarsetime)
and works on Rust stable, beta, and nightly.
Windows and Unix-like systems are supported.
Available feature:
* `wasi-abi2`: when targeting WASI, use the second preview of the ABI. Default is to use the regular WASI-core ABI.
# Documentation
[API documentation](https://docs.rs/coarsetime)
# Example
```rust
extern crate coarsetime;
use coarsetime::{Duration, Instant, Updater};
// Get the current instant. This may require a system call, but it may also
// be faster than the stdlib equivalent.
let now = Instant::now();
// Get the latest known instant. This operation is super fast.
// In this case, the value will be identical to `now`, because we haven't
// updated the latest known instant yet.
let ts1 = Instant::recent();
// Update the latest known instant. This may require a system call.
// Note that a call to `Instant::now()` also updates the stored instant.
Instant::update();
// Now, we may get a different instant. This call is also super fast.
let ts2 = Instant::recent();
// Compute the time elapsed between ts2 and ts1.
let elapsed_ts2_ts1 = ts2.duration_since(ts1);
// Operations such as `+` and `-` between `Instant` and `Duration` are also
// available.
let elapsed_ts2_ts1 = ts2 - ts1;
// Returns the time elapsed since ts1.
// This retrieves the actual current time, and may require a system call.
let elapsed_since_ts1 = ts1.elapsed();
// Returns the approximate time elapsed since ts1.
// This uses the latest known instant, and is super fast.
let elapsed_since_recent = ts1.elapsed_since_recent();
// Instant::update() should be called periodically, for example using an
// event loop. Alternatively, the crate provides an easy way to spawn a
// background task that will periodically update the latest known instant.
// Here, the update will happen every 250ms.
let updater = Updater::new(250).start().unwrap();
// From now on, Instant::recent() will always return an approximation of the
// current instant.
let ts3 = Instant::recent();
// Stop the task.
updater.stop().unwrap();
// Returns the elapsed time since the UNIX epoch
let unix_timestamp = Clock::now_since_epoch();
// Returns an approximation of the elapsed time since the UNIX epoch, based on
// the latest time update
let unix_timestamp_approx = Clock::recent_since_epoch();
```

View File

@@ -1,61 +0,0 @@
use benchmark_simple::*;
use coarsetime::*;
use std::time;
fn main() {
let options = &Options {
iterations: 250_000,
warmup_iterations: 25_000,
min_samples: 5,
max_samples: 10,
max_rsd: 1.0,
..Default::default()
};
bench_coarsetime_now(options);
bench_coarsetime_recent(options);
bench_coarsetime_elapsed(options);
bench_coarsetime_elapsed_since_recent(options);
bench_stdlib_now(options);
bench_stdlib_elapsed(options);
}
fn bench_coarsetime_now(options: &Options) {
let b = Bench::new();
Instant::update();
let res = b.run(options, Instant::now);
println!("coarsetime_now(): {}", res.throughput(1));
}
fn bench_coarsetime_recent(options: &Options) {
let b = Bench::new();
Instant::update();
let res = b.run(options, Instant::recent);
println!("coarsetime_recent(): {}", res.throughput(1));
}
fn bench_coarsetime_elapsed(options: &Options) {
let b = Bench::new();
let ts = Instant::now();
let res = b.run(options, || ts.elapsed());
println!("coarsetime_elapsed(): {}", res.throughput(1));
}
fn bench_coarsetime_elapsed_since_recent(options: &Options) {
let b = Bench::new();
let ts = Instant::now();
let res = b.run(options, || ts.elapsed_since_recent());
println!("coarsetime_since_recent(): {}", res.throughput(1));
}
fn bench_stdlib_now(options: &Options) {
let b = Bench::new();
let res = b.run(options, time::Instant::now);
println!("stdlib_now(): {}", res.throughput(1));
}
fn bench_stdlib_elapsed(options: &Options) {
let b = Bench::new();
let ts = time::Instant::now();
let res = b.run(options, || ts.elapsed());
println!("stdlib_elapsed(): {}", res.throughput(1));
}

View File

@@ -1,55 +0,0 @@
use std::thread::sleep;
use std::time;
#[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
use super::Updater;
use super::{Clock, Duration, Instant};
#[test]
fn tests() {
let ts = Instant::now();
let d = Duration::from_secs(2);
sleep(time::Duration::new(3, 0));
let elapsed = ts.elapsed().as_secs();
println!("Elapsed: {elapsed} secs");
assert!(elapsed >= 2);
assert!(elapsed < 100);
assert!(ts.elapsed_since_recent() > d);
let ts = Instant::now();
sleep(time::Duration::new(1, 0));
assert_eq!(Instant::recent(), ts);
Instant::update();
assert!(Instant::recent() > ts);
let clock_now = Clock::recent_since_epoch();
sleep(time::Duration::new(1, 0));
assert_eq!(Clock::recent_since_epoch(), clock_now);
assert!(Clock::now_since_epoch() > clock_now);
#[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
tests_updater();
}
#[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
#[test]
fn tests_updater() {
let updater = Updater::new(250)
.start()
.expect("Unable to start a background updater");
let ts = Instant::recent();
let clock_recent = Clock::recent_since_epoch();
sleep(time::Duration::new(2, 0));
assert!(Clock::recent_since_epoch() > clock_recent);
assert!(Instant::recent() != ts);
updater.stop().unwrap();
let clock_recent = Clock::recent_since_epoch();
sleep(time::Duration::new(1, 0));
assert_eq!(Clock::recent_since_epoch(), clock_recent);
}
#[test]
fn tests_duration() {
let duration = Duration::from_days(1000);
assert_eq!(duration.as_days(), 1000);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.