Compare commits

..

36 Commits

Author SHA1 Message Date
spinline
71456ff4d1 fix: mobil detay paneli tam ekran genişliğine alındı
All checks were successful
Build MIPS Binary / build (push) Successful in 2m3s
2026-02-21 22:03:09 +03:00
spinline
1a3099d926 fix: mobil detay paneli sağdan açılacak şekilde güncellendi
All checks were successful
Build MIPS Binary / build (push) Successful in 2m0s
2026-02-21 21:58:50 +03:00
spinline
4ef4ee8d45 feat: sheet yerine sayfaya sabit inline detay paneli eklendi
All checks were successful
Build MIPS Binary / build (push) Successful in 2m3s
2026-02-21 21:48:18 +03:00
spinline
253067b417 chore(frontend): remove debug console.log calls from app initialization
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 21:19:44 +03:00
spinline
8d5edc659f fix(frontend): use proper link tag for Cargo.toml instead of script tag to prevent syntax error
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 21:09:07 +03:00
spinline
c122290f37 build(frontend): add post_build hook to strip modulepreload tags preventing Safari warnings
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 21:03:48 +03:00
spinline
999cef34a7 fix(frontend): remove fake ScrollBar and use native styled webkit scrollbar in ScrollArea
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 20:51:27 +03:00
spinline
93a43d1b38 feat(frontend): implement rust-ui ScrollArea for details tab scrolling
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 20:44:20 +03:00
spinline
91ca6ff96f fix(frontend): adjust flex shrink and min heights for safe mobile scrolling in details tab
All checks were successful
Build MIPS Binary / build (push) Successful in 2m0s
2026-02-21 20:36:39 +03:00
spinline
8c0d35cca5 fix(frontend): force absolute positioning for tabs scroll
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 20:31:49 +03:00
spinline
444ea4326d fix(frontend): make general tab scrollable on mobile/safari
All checks were successful
Build MIPS Binary / build (push) Successful in 2m2s
2026-02-21 20:22:18 +03:00
spinline
401ccb69b2 fix(backend): Support TCP SCGI sockets and remove verbose unstandardized tracker fields
All checks were successful
Build MIPS Binary / build (push) Successful in 2m4s
2026-02-21 20:00:00 +03:00
spinline
9cfea2aed5 feat: implement trackers tab with details
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 16:58:14 +03:00
spinline
ce212cb2d6 feat: enhance torrent details general tab
All checks were successful
Build MIPS Binary / build (push) Successful in 1m57s
2026-02-21 16:33:10 +03:00
spinline
851d79029a feat(ui): add mobile long-press support for context menus
All checks were successful
Build MIPS Binary / build (push) Successful in 1m58s
2026-02-21 01:46:21 +03:00
spinline
ab27cf3eb4 fix: use f.priority.set instead of deprecated f.set_priority for rTorrent API
All checks were successful
Build MIPS Binary / build (push) Successful in 2m0s
2026-02-21 01:42:07 +03:00
spinline
7b4c9ff336 fix(files): trigger refetch only after set_priority completes on server
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 01:36:21 +03:00
spinline
743596d701 fix(ci): remove tracing calls that caused compile error in shared crate
All checks were successful
Build MIPS Binary / build (push) Successful in 1m58s
2026-02-21 01:31:25 +03:00
spinline
598f038ea6 fix(files): use refetch callback instead of Action to avoid reactive disposal panic
Some checks failed
Build MIPS Binary / build (push) Failing after 1m10s
2026-02-21 01:29:26 +03:00
spinline
7f8c721115 debug: add tracing logs to set_file_priority for diagnosis
Some checks failed
Build MIPS Binary / build (push) Failing after 1m10s
2026-02-21 01:27:22 +03:00
spinline
ba7f1ffd91 fix(ui): adjust context menu position for CSS transformed containers
All checks were successful
Build MIPS Binary / build (push) Successful in 1m59s
2026-02-21 01:22:31 +03:00
spinline
daa24dd7ec fix(ui): refactor FileContextMenu to auticlose and match homepage
All checks were successful
Build MIPS Binary / build (push) Successful in 1m59s
2026-02-21 01:13:57 +03:00
spinline
45271b5060 fix: add missing files.rs and resolve Show type inference
All checks were successful
Build MIPS Binary / build (push) Successful in 2m0s
2026-02-21 01:05:58 +03:00
spinline
4d02bc655d fix: resolve E0282 type inference on CI and clear warnings
Some checks failed
Build MIPS Binary / build (push) Failing after 35s
2026-02-21 01:04:00 +03:00
spinline
3c2ba477f5 feat(ui): add files tab and priority context menu
Some checks failed
Build MIPS Binary / build (push) Failing after 36s
2026-02-21 00:58:35 +03:00
spinline
6106d1cd22 fix(ui): prevent selection checkboxes from opening torrent details sheet
All checks were successful
Build MIPS Binary / build (push) Successful in 1m56s
2026-02-21 00:50:26 +03:00
spinline
50b83ebacf chore(ui): remove debugging logs for sse connection
All checks were successful
Build MIPS Binary / build (push) Successful in 1m56s
2026-02-21 00:44:50 +03:00
spinline
566308d889 feat(backend): require rTorrent to be running for backend to start
All checks were successful
Build MIPS Binary / build (push) Successful in 1m54s
2026-02-21 00:42:42 +03:00
spinline
e878d1fe33 chore(ui): add debug logs for SSE connection lifecycle
All checks were successful
Build MIPS Binary / build (push) Successful in 1m56s
2026-02-21 00:29:27 +03:00
spinline
d88084fb9a fix(ui): fix service worker crashes for chrome extensions and bump cache version
All checks were successful
Build MIPS Binary / build (push) Successful in 1m57s
2026-02-21 00:25:28 +03:00
spinline
f8639f2967 chore(ui): add debug logs for SSE deserialization errors
All checks were successful
Build MIPS Binary / build (push) Successful in 1m56s
2026-02-21 00:23:23 +03:00
spinline
7129c9a8eb fix(ui): prevent panic on unwrap when selected torrent is None
All checks were successful
Build MIPS Binary / build (push) Successful in 1m57s
2026-02-21 00:19:14 +03:00
spinline
91202e7cf8 refactor(ui): wrap torrent details content with official RustUI Shimmer
All checks were successful
Build MIPS Binary / build (push) Successful in 2m7s
2026-02-21 00:02:27 +03:00
spinline
3c2fec8b8c feat(ui): use official rustui shimmer component for torrent details 2026-02-20 23:57:59 +03:00
spinline
ec23285a6a feat(ui): add shimmer component and integrate into torrent details 2026-02-20 23:53:37 +03:00
spinline
f075a87668 feat(ui): add bottom sheet and tabs for torrent details 2026-02-20 23:50:23 +03:00
37 changed files with 4726 additions and 3809 deletions

View File

@@ -0,0 +1,83 @@
use shared::xmlrpc::{RtorrentClient, RpcParam, parse_multicall_response};
#[tokio::main]
async fn main() {
let mut client = None;
for path in ["127.0.0.1:8000", "0.0.0.0:8000", "localhost:8000"] {
let test_client = RtorrentClient::new(path);
match test_client.call("system.client_version", &[]).await {
Ok(res) => {
println!("SUCCESS: Connected to rTorrent at {} (Version: {})", path, res);
client = Some(test_client);
break;
}
Err(_) => {
// println!("Failed to connect to {}", path);
}
}
}
let client = match client {
Some(c) => c,
None => {
println!("Could not connect to rTorrent on port 8000.");
return;
}
};
let mut hash = String::new();
match client.call("d.multicall2", &[RpcParam::from(""), RpcParam::from("main"), RpcParam::from("d.hash=")]).await {
Ok(xml) => {
if let Ok(rows) = parse_multicall_response(&xml) {
if let Some(row) = rows.first() {
if let Some(h) = row.first() {
hash = h.clone();
println!("Using torrent hash: {}", hash);
}
}
}
},
Err(e) => {
println!("Error getting torrents: {:?}", e);
return;
},
}
if hash.is_empty() {
println!("No torrents found to test trackers.");
return;
}
// Now test Tracker fields one by one to see which one is failing
let fields = vec![
"t.url=",
"t.is_enabled=",
"t.group=",
"t.scrape_complete=",
"t.scrape_incomplete=",
"t.scrape_downloaded=",
"t.activity_date_last=",
"t.normal_interval=",
"t.message=",
];
for field in &fields {
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from(*field),
];
print!("Testing field {:<22} : ", field);
match client.call("t.multicall", &params).await {
Ok(xml) => {
if xml.contains("faultCode") {
println!("FAILED");
} else {
println!("SUCCESS");
}
},
Err(e) => println!("ERROR: {:?}", e),
}
}
}

View File

@@ -243,12 +243,14 @@ async fn main() {
let socket_path = std::path::Path::new(&args.socket);
if !socket_path.exists() {
tracing::error!("CRITICAL: rTorrent socket not found at {:?}.", socket_path);
tracing::warn!(
tracing::error!(
"HINT: Make sure rTorrent is running and the SCGI socket is enabled in .rtorrent.rc"
);
tracing::warn!(
tracing::error!(
"HINT: You can configure the socket path via --socket ARG or RTORRENT_SOCKET ENV."
);
tracing::error!("FATAL: VibeTorrent cannot start without a running rTorrent instance. Exiting.");
std::process::exit(1);
} else {
tracing::info!("Socket file exists. Testing connection...");
let client = xmlrpc::RtorrentClient::new(&args.socket);
@@ -259,7 +261,11 @@ async fn main() {
let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml);
tracing::info!("Connected to rTorrent successfully. Version: {}", version);
}
Err(e) => tracing::error!("Socket exists but failed to connect to rTorrent: {}", e),
Err(e) => {
tracing::error!("CRITICAL: Socket exists but failed to connect to rTorrent: {}", e);
tracing::error!("FATAL: Ensure rTorrent is fully started and the socket has correct permissions. Exiting.");
std::process::exit(1);
}
}
}

View File

@@ -51,6 +51,21 @@ mod fields {
pub const IDX_LABEL: usize = 12;
pub const CMD_LABEL: &str = "d.custom1=";
pub const IDX_RATIO: usize = 13;
pub const CMD_RATIO: &str = "d.ratio=";
pub const IDX_UPLOADED: usize = 14;
pub const CMD_UPLOADED: &str = "d.up.total=";
pub const IDX_WASTED: usize = 15;
pub const CMD_WASTED: &str = "d.skip.total=";
pub const IDX_SAVE_PATH: usize = 16;
pub const CMD_SAVE_PATH: &str = "d.base_path=";
pub const IDX_FREE_DISK: usize = 17;
pub const CMD_FREE_DISK: &str = "d.free_diskspace=";
}
use fields::*;
@@ -72,6 +87,11 @@ const RTORRENT_FIELDS: &[&str] = &[
CMD_CREATION_DATE,
CMD_HASHING,
CMD_LABEL,
CMD_RATIO,
CMD_UPLOADED,
CMD_WASTED,
CMD_SAVE_PATH,
CMD_FREE_DISK,
];
fn parse_long(s: Option<&String>) -> i64 {
@@ -98,6 +118,11 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
let added_date = parse_long(row.get(IDX_CREATION_DATE));
let is_hashing = parse_long(row.get(IDX_HASHING));
let label_raw = parse_string(row.get(IDX_LABEL));
let ratio = parse_long(row.get(IDX_RATIO)) as f64 / 1000.0;
let uploaded = parse_long(row.get(IDX_UPLOADED));
let wasted = parse_long(row.get(IDX_WASTED));
let save_path = parse_string(row.get(IDX_SAVE_PATH));
let free_disk_space = parse_long(row.get(IDX_FREE_DISK));
let label = if label_raw.is_empty() {
None
@@ -144,6 +169,11 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
error_message: message,
added_date,
label,
ratio,
uploaded,
wasted,
save_path,
free_disk_space,
}
}

View File

@@ -7,6 +7,11 @@ stage = "build"
command = "sh"
command_arguments = ["-c", "npx @tailwindcss/cli -i input.css -o public/tailwind.css"]
[[hooks]]
stage = "post_build"
command = "sh"
command_arguments = ["-c", "sed -i '' -e 's/<link rel=\"modulepreload\"[^>]*>//g' -e 's/<link rel=\"preload\"[^>]*>//g' \"$TRUNK_STAGING_DIR/index.html\" || sed -i -e 's/<link rel=\"modulepreload\"[^>]*>//g' -e 's/<link rel=\"preload\"[^>]*>//g' \"$TRUNK_STAGING_DIR/index.html\""]
[build]
target = "index.html"
dist = "dist"

View File

@@ -20,7 +20,7 @@
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<!-- Trunk Assets -->
<script data-trunk rel="rust" src="Cargo.toml" data-wasm-opt="0" data-preload="false"></script>
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" />
<link data-trunk rel="css" href="public/tailwind.css" />
<link data-trunk rel="copy-file" href="manifest.json" />
<link data-trunk rel="copy-file" href="icon-192.png" />

View File

@@ -45,8 +45,15 @@
--ring: oklch(0.556 0 0);
}
@theme inline {
--animate-shimmer: shimmer 2s infinite;
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -76,6 +83,7 @@
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
@@ -88,4 +96,4 @@
dialog {
margin: auto;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -49,14 +49,10 @@ fn InnerApp() -> impl IntoView {
Effect::new(move |_| {
spawn_local(async move {
log::info!("App initialization started...");
gloo_console::log!("APP INIT: Checking setup status...");
// 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");
needs_setup.1.set(true);
is_loading.1.set(false);
return;
@@ -68,15 +64,12 @@ fn InnerApp() -> impl IntoView {
// 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);
}
Ok(None) => {
log::info!("Not authenticated");
}
Ok(None) => {}
Err(e) => {
log::error!("Auth check failed: {:?}", e);
}
@@ -111,7 +104,6 @@ fn InnerApp() -> impl IntoView {
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());
}
@@ -135,10 +127,8 @@ fn InnerApp() -> impl IntoView {
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 File

@@ -0,0 +1,91 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use serde::{Deserialize, Serialize};
use crate::components::ui::button::{Button, ButtonVariant};
use crate::components::ui::card::{Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle};
use crate::components::ui::shimmer::Shimmer;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardData {
pub title: String,
pub description: String,
}
/// Simulates a database fetch with 1 second delay
#[server]
pub async fn fetch_card_data() -> Result<CardData, ServerFnError> {
// Simulate network/database latency (only on server)
#[cfg(feature = "ssr")]
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(CardData {
title: "Fetched Title".to_string(),
description: "This content was fetched from the server after a 1 second simulated delay. The shimmer effect automatically showed during the loading period.".to_string(),
})
}
#[component]
pub fn DemoShimmer() -> impl IntoView {
// Loading state
let loading = RwSignal::new(false);
// Store fetched data
let card_data = RwSignal::new(None::<CardData>);
// Fetch handler using spawn_local for reliable repeated calls
let on_fetch = move |_| {
spawn_local(async move {
loading.set(true);
let result = fetch_card_data().await;
if let Ok(data) = result {
card_data.set(Some(data));
}
loading.set(false);
});
};
view! {
<div class="flex flex-col gap-4">
<div class="flex gap-2">
<Button variant=ButtonVariant::Outline on:click=move |_| loading.set(!loading.get())>
"Toggle Loading"
</Button>
<Button variant=ButtonVariant::Default on:click=on_fetch>
"Fetch Data (1s)"
</Button>
</div>
<Shimmer loading=Signal::from(loading)>
<Card class="max-w-lg lg:max-w-2xl">
<CardHeader>
<CardTitle>
{move || {
card_data.get().map(|data| data.title).unwrap_or_else(|| "Card Title".to_string())
}}
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>
{move || {
card_data
.get()
.map(|data| data.description)
.unwrap_or_else(|| {
"Click 'Toggle Loading' for manual control, or 'Fetch Data' to simulate a real server call with 1 second delay."
.to_string()
})
}}
</CardDescription>
</CardContent>
<CardFooter class="justify-end">
<Button variant=ButtonVariant::Outline>"Cancel"</Button>
<Button>"Confirm"</Button>
</CardFooter>
</Card>
</Shimmer>
</div>
}
}

View File

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

View File

@@ -1,5 +1,31 @@
// use leptos::prelude::*;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicUsize, Ordering};
pub fn use_random_id_for(prefix: &str) -> String {
format!("{}_{}", prefix, js_sys::Math::random().to_string().replace(".", ""))
const PREFIX: &str = "rust_ui"; // Must NOT contain "/" or "-"
pub fn use_random_id() -> String {
format!("_{PREFIX}_{}", generate_hash())
}
pub fn use_random_id_for(element: &str) -> String {
format!("{}_{PREFIX}_{}", element, generate_hash())
}
pub fn use_random_transition_name() -> String {
let random_id = use_random_id();
format!("view-transition-name: {random_id}")
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
static COUNTER: AtomicUsize = AtomicUsize::new(1);
fn generate_hash() -> u64 {
let mut hasher = DefaultHasher::new();
let counter = COUNTER.fetch_add(1, Ordering::SeqCst);
counter.hash(&mut hasher);
hasher.finish()
}

View File

@@ -3,53 +3,20 @@ use crate::components::layout::sidebar::Sidebar;
use crate::components::layout::toolbar::Toolbar;
use crate::components::layout::footer::Footer;
use crate::components::ui::sidenav::{SidenavWrapper, Sidenav, SidenavInset};
use wasm_bindgen::JsCast;
#[component]
pub fn Protected(children: Children) -> impl IntoView {
let (collapsed, set_collapsed) = signal(false);
// Responsive Sidebar Logic
Effect::new(move |_| {
let window = web_sys::window().expect("window missing");
// Initial check
let width = window.inner_width().unwrap().as_f64().unwrap_or(1920.0);
if width < 1280.0 {
set_collapsed.set(true);
} else {
set_collapsed.set(false);
}
// Listener
let closure = wasm_bindgen::closure::Closure::<dyn FnMut(_)>::new(move |_: web_sys::Event| {
let window = web_sys::window().expect("window missing");
let width = window.inner_width().unwrap().as_f64().unwrap_or(1920.0);
if width < 1280.0 {
set_collapsed.set(true);
} else {
set_collapsed.set(false);
}
});
let _ = window.add_event_listener_with_callback("resize", closure.as_ref().unchecked_ref());
closure.forget(); // Leak memory intentionally for global listener (or store in a cleanup handle if needed, but for layout component it's fine)
});
view! {
<SidenavWrapper attr:style="--sidenav-width:16rem; --sidenav-width-icon:3rem;">
// Masaüstü Sidenav
<Sidenav
data_collapsible=crate::components::ui::sidenav::SidenavCollapsible::Icon
data_state=if collapsed.get() { crate::components::ui::sidenav::SidenavState::Collapsed } else { crate::components::ui::sidenav::SidenavState::Expanded }
>
<Sidenav>
<Sidebar />
</Sidenav>
// İçerik Alanı
<SidenavInset class="flex flex-col h-screen overflow-hidden">
// Toolbar (Üst Bar)
<Toolbar on_toggle_sidebar=Callback::new(move |_| set_collapsed.update(|c| *c = !*c)) />
<Toolbar />
// Ana İçerik
<main class="flex-1 overflow-y-auto relative bg-background flex flex-col">

View File

@@ -87,7 +87,7 @@ pub fn Sidebar() -> impl IntoView {
<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" />
</svg>
</div>
<div class="grid flex-1 text-left text-sm leading-tight overflow-hidden group-data-[state=Collapsed]:hidden">
<div class="grid flex-1 text-left text-sm leading-tight overflow-hidden">
<span class="truncate font-semibold text-foreground text-base">"VibeTorrent"</span>
<span class="truncate text-[10px] text-muted-foreground opacity-70">"v3.0.0"</span>
</div>
@@ -150,28 +150,26 @@ pub fn Sidebar() -> impl IntoView {
<div class="flex flex-col gap-4 p-4">
// Push Notification Toggle
<div class="flex items-center justify-between px-2 py-1 bg-muted/20 rounded-md border border-border/50">
<div class="flex flex-col gap-0.5 group-data-[state=Collapsed]:hidden">
<div class="flex flex-col gap-0.5">
<span class="text-[10px] font-bold uppercase tracking-wider text-foreground/70">"Bildirimler"</span>
<span class="text-[9px] text-muted-foreground">"Web Push"</span>
</div>
<div class="group-data-[state=Collapsed]:hidden">
<Switch
checked=Signal::from(store.push_enabled)
on_checked_change=Callback::new(on_push_toggle)
/>
</div>
<Switch
checked=Signal::from(store.push_enabled)
on_checked_change=Callback::new(on_push_toggle)
/>
</div>
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden group-data-[state=Collapsed]:gap-0 group-data-[state=Collapsed]:justify-center group-data-[state=Collapsed]:p-0 group-data-[state=Collapsed]:border-none group-data-[state=Collapsed]:bg-transparent group-data-[state=Collapsed]:shadow-none">
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0 border border-primary-foreground/10">
{first_letter}
</div>
<div class="flex-1 overflow-hidden group-data-[state=Collapsed]:hidden">
<div class="flex-1 overflow-hidden">
<div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div>
<div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div>
</div>
<div class="flex items-center gap-1 group-data-[state=Collapsed]:hidden">
<div class="flex items-center gap-1">
<ThemeToggle />
<Button
@@ -219,8 +217,8 @@ fn SidebarItem(
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d=icon.clone() />
</svg>
<span class="flex-1 truncate group-data-[state=Collapsed]:hidden">{label}</span>
<span class="text-[10px] font-mono opacity-50 group-data-[state=Collapsed]:hidden">{count}</span>
<span class="flex-1 truncate">{label}</span>
<span class="text-[10px] font-mono opacity-50">{count}</span>
</SidenavMenuButton>
</SidenavMenuItem>
}

View File

@@ -1,38 +1,24 @@
use leptos::prelude::*;
use icons::{PanelLeft, Plus};
use crate::components::torrent::add_torrent::AddTorrentDialogContent;
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
use crate::components::ui::button::{ButtonVariant, ButtonSize};
use crate::components::ui::sheet::{Sheet, SheetContent, SheetTrigger, SheetDirection};
use crate::components::ui::dialog::{Dialog, DialogContent, DialogTrigger};
use crate::components::layout::sidebar::Sidebar;
#[component]
pub fn Toolbar(
on_toggle_sidebar: Callback<()>,
) -> impl IntoView {
pub fn Toolbar() -> impl IntoView {
view! {
<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);">
// Sol kısım: Menü butonu (Mobil) + Add Torrent
<div class="flex items-center gap-3">
// Desktop Toggle
<div class="hidden lg:block">
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon
class="size-9"
on:click=move |_| { on_toggle_sidebar.run(()); }
>
<PanelLeft class="size-5" />
<span class="hidden">"Toggle Sidebar"</span>
</Button>
</div>
// Mobile Toggle (Sheet)
// --- MOBILE SHEET (SIDEBAR) ---
<div class="lg:hidden">
<Sheet>
<SheetTrigger variant=ButtonVariant::Ghost size=ButtonSize::Icon class="size-9">
<PanelLeft class="size-5" />
<span class="hidden">"Open Menu"</span>
<span class="hidden">"Menüyü Aç"</span>
</SheetTrigger>
<SheetContent
direction=SheetDirection::Left

View File

@@ -5,3 +5,4 @@ pub mod torrent;
pub mod auth;
// pub mod toast; (Removed)
pub mod ui;
pub mod demos;

View File

@@ -0,0 +1,260 @@
use leptos::prelude::*;
use crate::components::ui::tabs::*;
use crate::components::ui::skeleton::*;
#[component]
pub fn TorrentDetailsPanel() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let selected_torrent = Memo::new(move |_| {
let hash = store.selected_torrent.get()?;
store.torrents.with(|map| map.get(&hash).cloned())
});
let is_open = Signal::derive(move || store.selected_torrent.get().is_some());
view! {
// Mobil overlay backdrop
<div
class=move || if is_open.get() {
"fixed inset-0 bg-black/40 z-30 md:hidden backdrop-blur-sm transition-opacity duration-300 opacity-100"
} else {
"fixed inset-0 bg-black/0 z-30 md:hidden pointer-events-none transition-opacity duration-300 opacity-0"
}
on:click=move |_| store.selected_torrent.set(None)
/>
// Panel — masaüstünde sağ kolonda sabit, mobilde sağdan açılan overlay
<div class=move || {
if is_open.get() {
// Açık: masaüstünde görünür, mobilde sağdan gelir
"w-full md:w-[380px] md:min-w-[380px] shrink-0 \
flex flex-col border-l border-border bg-card \
fixed top-0 right-0 bottom-0 z-40 \
translate-x-0 \
md:static md:z-auto md:translate-x-0 \
transition-transform duration-300 ease-out shadow-2xl md:shadow-none"
} else {
// Kapalı: masaüstünde gizli, mobilde sağa kayar
"w-full md:w-0 shrink-0 overflow-hidden border-none \
fixed top-0 right-0 bottom-0 z-40 \
translate-x-full \
md:static md:z-auto md:translate-x-0 \
transition-transform duration-300 ease-in pointer-events-none"
}
}>
// İpucu: panel kapalıyken içeriği render etme
<Show when=move || is_open.get()>
// Başlık
<div class="px-4 py-3 border-b flex items-center justify-between shrink-0 bg-card">
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<Show
when=move || selected_torrent.get().is_some()
fallback=move || view! { <Skeleton class="h-5 w-40" /> }
>
<h2 class="font-bold text-sm truncate leading-tight">
{move || selected_torrent.get().map(|t| t.name).unwrap_or_default()}
</h2>
</Show>
<Show
when=move || selected_torrent.get().is_some()
fallback=move || view! { <Skeleton class="h-3 w-20 mt-1" /> }
>
<p class="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold flex items-center gap-1.5">
{move || selected_torrent.get().map(|t| format!("{:?}", t.status)).unwrap_or_default()}
<span class="bg-primary/20 text-primary px-1 py-0.5 rounded text-[9px] lowercase">
{move || selected_torrent.get().map(|t| format!("{:.1}%", t.percent_complete)).unwrap_or_default()}
</span>
</p>
</Show>
</div>
// Kapat butonu
<button
class="rounded-full p-1.5 hover:bg-muted transition-colors text-muted-foreground hover:text-foreground shrink-0 ml-2"
on:click=move |_| store.selected_torrent.set(None)
>
<icons::X class="size-4" />
</button>
</div>
// Sekmeler + içerik
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<Tabs default_value="general" class="flex-1 h-full min-h-0 flex flex-col">
<TabsList class="w-full justify-start rounded-none border-b bg-transparent p-0 shrink-0 px-2">
<TabsTrigger
value="general"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none text-xs h-9"
>
"Genel"
</TabsTrigger>
<TabsTrigger
value="files"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none text-xs h-9"
>
"Dosyalar"
</TabsTrigger>
<TabsTrigger
value="trackers"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none text-xs h-9"
>
"İzleyiciler"
</TabsTrigger>
<TabsTrigger
value="peers"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none text-xs h-9"
>
"Eşler"
</TabsTrigger>
</TabsList>
<crate::components::ui::scroll_area::ScrollArea class="flex-1 min-h-0">
<TabsContent value="general" class="p-4 space-y-5 animate-in fade-in duration-200">
<crate::components::ui::shimmer::Shimmer
loading=Signal::derive(move || selected_torrent.get().is_none())
shimmer_color="rgba(0,0,0,0.06)"
background_color="rgba(0,0,0,0.04)"
>
{move || {
let t = selected_torrent.get().unwrap_or_else(|| shared::Torrent {
hash: "----------------------------------------".to_string(),
name: "Yükleniyor...".to_string(),
size: 0,
completed: 0,
down_rate: 0,
up_rate: 0,
eta: 0,
percent_complete: 0.0,
status: shared::TorrentStatus::Downloading,
error_message: "".to_string(),
added_date: 0,
label: None,
ratio: 0.0,
uploaded: 0,
wasted: 0,
save_path: "Yükleniyor...".to_string(),
free_disk_space: 0,
});
view! {
<div class="flex flex-col gap-5">
// Aktarım
<div>
<h3 class="text-[10px] font-bold border-b pb-1.5 mb-3 uppercase tracking-widest text-muted-foreground">"Aktarım"</h3>
<div class="grid grid-cols-2 gap-3">
<InfoItem label="Kalan" value=format_duration(t.eta) />
<InfoItem label="Paylaşım Oranı" value=format!("{:.3}", t.ratio) />
<InfoItem label="İndirilen" value=format_bytes(t.completed) />
<InfoItem label="İndirme Hızı" value=format_speed(t.down_rate) class="text-blue-500" />
<InfoItem label="Gönderilen" value=format_bytes(t.uploaded) />
<InfoItem label="Gönderme Hızı" value=format_speed(t.up_rate) class="text-green-500" />
<InfoItem label="Boşa Giden" value=format_bytes(t.wasted) />
</div>
</div>
// Genel
<div>
<h3 class="text-[10px] font-bold border-b pb-1.5 mb-3 uppercase tracking-widest text-muted-foreground">"Genel"</h3>
<div class="flex flex-col gap-3">
<InfoItem label="Kaydedilen Yer" value=t.save_path class="break-all font-mono text-xs" />
<div class="grid grid-cols-2 gap-3">
<InfoItem label="Boş Disk Alanı" value=format_bytes(t.free_disk_space) />
<InfoItem label="Oluşturulma Tarihi" value=format_date(t.added_date) />
</div>
<InfoItem label="Hash" value=t.hash class="break-all font-mono text-[10px]" />
</div>
</div>
</div>
}
}}
</crate::components::ui::shimmer::Shimmer>
</TabsContent>
<TabsContent value="files" class="h-full">
{move || match selected_torrent.get() {
Some(t) => leptos::either::Either::Left(view! {
<div class="h-full">
<crate::components::torrent::files::TorrentFilesTab hash=t.hash />
</div>
}),
None => leptos::either::Either::Right(view! {
<div class="flex flex-col items-center justify-center h-48 opacity-60 gap-2">
<icons::File class="size-10 text-muted-foreground" />
<p class="text-sm font-medium">"Dosya yükleniyor..."</p>
</div>
}),
}}
</TabsContent>
<TabsContent value="trackers" class="h-full">
{move || match selected_torrent.get() {
Some(t) => leptos::either::Either::Left(view! {
<div class="h-full">
<crate::components::torrent::trackers::TorrentTrackersTab hash=t.hash />
</div>
}),
None => leptos::either::Either::Right(view! {
<div class="flex flex-col items-center justify-center h-48 opacity-60 gap-2">
<icons::Settings2 class="size-10 text-muted-foreground" />
<p class="text-sm font-medium">"İzleyici yükleniyor..."</p>
</div>
}),
}}
</TabsContent>
<TabsContent value="peers" class="h-full">
<div class="flex flex-col items-center justify-center h-48 opacity-60 gap-2">
<icons::Users class="size-10 text-muted-foreground" />
<p class="text-sm font-medium">"Eş listesi yakında eklenecek"</p>
</div>
</TabsContent>
</crate::components::ui::scroll_area::ScrollArea>
</Tabs>
</div>
</Show>
</div>
}
}
#[component]
fn InfoItem(
label: &'static str,
value: String,
#[prop(optional)] class: &'static str
) -> impl IntoView {
view! {
<div class=tailwind_fuse::tw_merge!("flex flex-col gap-0.5", class)>
<span class="text-[9px] font-semibold text-muted-foreground uppercase tracking-wider opacity-70">{label}</span>
<span class="text-xs font-medium leading-tight">{value}</span>
</div>
}
}
fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
if bytes < 1024 { return format!("{} B", bytes); }
let i = (bytes as f64).log2().div_euclid(10.0) as usize;
format!("{:.1} {}", (bytes as f64) / 1024_f64.powi(i as i32), UNITS[i])
}
fn format_speed(bytes_per_sec: i64) -> String {
if bytes_per_sec == 0 { return "0 B/s".to_string(); }
format!("{}/s", format_bytes(bytes_per_sec))
}
fn format_duration(seconds: i64) -> String {
if seconds <= 0 { return "".to_string(); }
let days = seconds / 86400;
let hours = (seconds % 86400) / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
if days > 0 { format!("{}g {}s", days, hours) }
else if hours > 0 { format!("{}s {}d", hours, minutes) }
else if minutes > 0 { format!("{}d {}sn", minutes, secs) }
else { format!("{}sn", secs) }
}
fn format_date(timestamp: i64) -> String {
if timestamp <= 0 { return "N/A".to_string(); }
let dt = chrono::DateTime::from_timestamp(timestamp, 0);
match dt { Some(dt) => dt.format("%d/%m/%Y %H:%M").to_string(), None => "N/A".to_string() }
}

View File

@@ -0,0 +1,216 @@
use leptos::prelude::*;
use crate::components::ui::table::*;
use crate::components::ui::badge::*;
use crate::components::ui::shimmer::*;
use crate::components::ui::context_menu::*;
use shared::TorrentFile;
#[component]
pub fn TorrentFilesTab(hash: String) -> impl IntoView {
let hash_clone = hash.clone();
// Fetch files resource
let files_resource = Resource::new(
move || hash_clone.clone(),
|h| async move { shared::server_fns::torrent::get_files(h).await.unwrap_or_default() }
);
// Callback to trigger a refetch — safe, doesn't destroy existing components
let on_refresh = Callback::new(move |_: ()| {
files_resource.refetch();
});
let stored_hash = StoredValue::new(hash);
view! {
<Suspense fallback=move || view! { <FilesFallback /> }>
{move || {
let files = files_resource.get().unwrap_or_default();
if files.is_empty() {
return view! {
<div class="flex flex-col items-center justify-center h-48 opacity-60">
<icons::File class="size-12 mb-3 text-muted-foreground" />
<p class="text-sm font-medium">"Bu torrent için dosya bulunamadı."</p>
</div>
}.into_any();
}
let files_len = files.len();
view! {
<div class="space-y-4">
<TableWrapper class="bg-card/50">
<Table>
<TableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
<TableRow class="hover:bg-transparent">
<TableHead class="w-12 text-center text-xs">"#"</TableHead>
<TableHead class="text-xs">"Dosya Adı"</TableHead>
<TableHead class="w-24 text-right text-xs">"Boyut"</TableHead>
<TableHead class="w-24 text-right text-xs">"Tamamlanan"</TableHead>
<TableHead class="w-24 text-center text-xs">"Öncelik"</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For
each=move || files.clone()
key=|f| f.index
children={move |f| {
let p_hash = stored_hash.get_value();
view! {
<FileRow
file=f
hash=p_hash
on_refresh=on_refresh.clone()
/>
}
}}
/>
</TableBody>
</Table>
</TableWrapper>
<div class="flex items-center justify-between text-xs text-muted-foreground px-1">
<span>{format!("Toplam {} dosya", files_len)}</span>
</div>
</div>
}.into_any()
}}
</Suspense>
}
}
#[component]
fn FileRow(file: TorrentFile, hash: String, on_refresh: Callback<()>) -> impl IntoView {
let f_idx = file.index;
let path_clone = file.path.clone();
// on_refresh is called AFTER the server responds, not before
let on_refresh_stored = StoredValue::new(on_refresh);
let set_priority = Action::new(move |req: &(String, u32, u8)| {
let (h, idx, p) = req.clone();
async move {
let res = shared::server_fns::torrent::set_file_priority(h, idx, p).await;
if let Err(e) = &res {
crate::store::show_toast(shared::NotificationLevel::Error, format!("Öncelik değiştirilemedi: {:?}", e));
} else {
crate::store::show_toast(shared::NotificationLevel::Success, "Dosya önceliği güncellendi.".to_string());
// Refetch AFTER the server has saved the priority
on_refresh_stored.get_value().run(());
}
res
}
});
view! {
<FileContextMenu
torrent_hash=hash
file_index=f_idx
set_priority=set_priority
>
<TableRow class="hover:bg-muted/50 transition-colors group">
<TableCell class="text-center text-xs text-muted-foreground">{file.index}</TableCell>
<TableCell class="font-medium text-xs break-all max-w-[200px] md:max-w-md" attr:title=move || path_clone.clone()>
{file.path.clone()}
</TableCell>
<TableCell class="text-right text-xs text-muted-foreground whitespace-nowrap">
{format_bytes(file.size)}
</TableCell>
<TableCell class="text-right text-xs whitespace-nowrap">
<span class="text-primary font-medium">{format_bytes(file.completed_chunks)}</span>
</TableCell>
<TableCell class="text-center">
{
let (variant, label) = match file.priority {
0 => (BadgeVariant::Destructive, "İndirme"),
2 => (BadgeVariant::Success, "Yüksek"),
_ => (BadgeVariant::Secondary, "Normal"),
};
view! { <Badge variant=variant class="text-[10px] uppercase">{label}</Badge> }
}
</TableCell>
</TableRow>
</FileContextMenu>
}
}
#[component]
fn FileContextMenu(
children: Children,
torrent_hash: String,
file_index: u32,
set_priority: Action<(String, u32, u8), Result<(), ServerFnError>>,
) -> impl IntoView {
let hash_c1 = torrent_hash.clone();
let hash_c2 = torrent_hash.clone();
let hash_c3 = torrent_hash.clone();
view! {
<ContextMenu>
<ContextMenuTrigger>
{children()}
</ContextMenuTrigger>
<ContextMenuContent class="w-48">
<ContextMenuLabel>"Dosya Önceliği"</ContextMenuLabel>
<ContextMenuGroup>
<ContextMenuItem on:click={
let h = hash_c1;
let sp = set_priority.clone();
move |_| {
sp.dispatch((h.clone(), file_index, 2));
crate::components::ui::context_menu::close_context_menu();
}
}>
<icons::ChevronsUp class="text-green-500" />
<span>"Yüksek"</span>
</ContextMenuItem>
<ContextMenuItem on:click={
let h = hash_c2;
let sp = set_priority.clone();
move |_| {
sp.dispatch((h.clone(), file_index, 1));
crate::components::ui::context_menu::close_context_menu();
}
}>
<icons::Minus class="text-blue-500" />
<span>"Normal"</span>
</ContextMenuItem>
<ContextMenuItem class="text-destructive focus:bg-destructive/10" on:click={
let h = hash_c3;
let sp = set_priority.clone();
move |_| {
sp.dispatch((h.clone(), file_index, 0));
crate::components::ui::context_menu::close_context_menu();
}
}>
<icons::X />
<span>"İndirme (Kapalı)"</span>
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
}
}
#[component]
fn FilesFallback() -> impl IntoView {
view! {
<Shimmer loading=Signal::derive(|| true) class="space-y-2">
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
</Shimmer>
}
}
fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
if bytes < 1024 { return format!("{} B", bytes); }
let i = (bytes as f64).log2().div_euclid(10.0) as usize;
format!("{:.1} {}", (bytes as f64) / 1024_f64.powi(i as i32), UNITS[i])
}

View File

@@ -1,2 +1,5 @@
pub mod table;
pub mod add_torrent;
pub mod details;
pub mod files;
pub mod trackers;

View File

@@ -218,7 +218,9 @@ pub fn TorrentTable() -> impl IntoView {
});
view! {
<div class="h-full bg-background relative flex flex-col overflow-hidden px-4 py-4 gap-4">
<div class="h-full bg-background flex flex-row overflow-hidden">
// Sol: liste alanı
<div class="flex-1 min-w-0 flex flex-col overflow-hidden px-4 py-4 gap-4">
// --- TOPBAR ---
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2 flex-1 max-w-md">
@@ -249,7 +251,6 @@ pub fn TorrentTable() -> impl IntoView {
<div class="my-1 h-px bg-border" />
// Trigger the hidden AlertDialog from this menu item
<DropdownMenuItem class="text-destructive focus:bg-destructive/10 cursor-pointer" on:click=move |_| {
if let Some(trigger) = document().get_element_by_id("bulk-delete-trigger") {
let _ = trigger.dyn_into::<web_sys::HtmlElement>().map(|el: web_sys::HtmlElement| el.click());
@@ -261,7 +262,6 @@ pub fn TorrentTable() -> impl IntoView {
</DropdownMenuContent>
</DropdownMenu>
// Hidden AlertDialog moved outside the DropdownMenuContent to ensure proper centering
<AlertDialog>
<AlertDialogTrigger attr:id="bulk-delete-trigger" class="hidden">""</AlertDialogTrigger>
<AlertDialogContent class="sm:max-w-[425px]">
@@ -554,6 +554,10 @@ pub fn TorrentTable() -> impl IntoView {
<div class="opacity-50">"VibeTorrent v3"</div>
</div>
</div>
// Sağ: sabit detay paneli
<crate::components::torrent::details::TorrentDetailsPanel />
</div>
}.into_any()
}
@@ -595,10 +599,12 @@ fn TorrentRow(
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
>
<DataTableCell class="w-12 px-4">
<Checkbox
checked=is_selected
on_checked_change=on_select
/>
<div on:click=move |e| e.stop_propagation()>
<Checkbox
checked=is_selected
on_checked_change=on_select
/>
</div>
</DataTableCell>
{move || visible_columns.get().contains("Name").then({
@@ -728,17 +734,23 @@ fn TorrentCard(
}
)
on:click=move |_| {
let current = is_selected.get();
on_select.run(!current);
store.selected_torrent.set(Some(stored_hash.get_value()));
}
>
<div class="p-4 space-y-3">
<div class="flex justify-between items-start gap-3">
<div class="flex-1 min-w-0">
<h3 class="text-sm font-bold leading-tight line-clamp-2 break-all">{t_name.clone()}</h3>
<div class="flex items-start gap-3 flex-1 min-w-0">
<div on:click=move |e| e.stop_propagation() class="mt-0.5">
<Checkbox
checked=is_selected
on_checked_change=on_select
/>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-bold leading-tight line-clamp-2 break-all">{t_name.clone()}</h3>
</div>
</div>
<Badge variant=status_variant class="uppercase tracking-wider text-[10px]">
<Badge variant=status_variant class="uppercase tracking-wider text-[10px] shrink-0">
{format!("{:?}", t.status)}
</Badge>
</div>

View File

@@ -0,0 +1,121 @@
use leptos::prelude::*;
use crate::components::ui::table::*;
use crate::components::ui::shimmer::*;
use shared::TorrentTracker;
#[component]
pub fn TorrentTrackersTab(hash: String) -> impl IntoView {
let hash_clone = hash.clone();
let trackers_resource = Resource::new(
move || hash_clone.clone(),
|h| async move { shared::server_fns::torrent::get_trackers(h).await.unwrap_or_default() }
);
view! {
<Suspense fallback=move || view! { <TrackersFallback /> }>
{move || {
let trackers = trackers_resource.get().unwrap_or_default();
if trackers.is_empty() {
return view! {
<div class="flex flex-col items-center justify-center h-48 opacity-60">
<icons::Settings2 class="size-12 mb-3 text-muted-foreground" />
<p class="text-sm font-medium">"Bu torrent için izleyici bulunamadı."</p>
</div>
}.into_any();
}
view! {
<div class="h-full overflow-auto">
<TableWrapper class="bg-card/50 whitespace-nowrap">
<Table>
<TableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
<TableRow class="hover:bg-transparent text-xs">
<TableHead>"İsim"</TableHead>
<TableHead class="text-center">"Tür"</TableHead>
<TableHead class="text-center">"Etkin"</TableHead>
<TableHead class="text-center">"Grup"</TableHead>
<TableHead class="text-center">"Ortaklar"</TableHead>
<TableHead class="text-center">"Eşler"</TableHead>
<TableHead class="text-center">"İndirilen"</TableHead>
<TableHead class="text-center">"Son Güncelleme"</TableHead>
<TableHead class="text-center">"Sıklık"</TableHead>
<TableHead class="text-center">"Özel"</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For
each=move || trackers.clone()
key=|t| t.url.clone()
children=move |t| {
let t_type = if t.url.starts_with("http") { "http" }
else if t.url.starts_with("udp") { "udp" }
else if t.url.starts_with("dht") { "dht" }
else { "diğer" };
let is_enabled = if t.is_enabled { "evet" } else { "hayır" };
// Format timestamp difference for last update
let now = chrono::Utc::now().timestamp();
let diff = now - t.last_updated;
let last_update_str = if t.last_updated == 0 {
"Güncellenmedi".to_string()
} else if diff >= 0 {
format_duration_short(diff)
} else {
"N/A".to_string()
};
let url_clone = t.url.clone();
view! {
<TableRow class="hover:bg-muted/50 transition-colors group text-xs text-muted-foreground">
<TableCell class="font-medium text-foreground max-w-[200px] md:max-w-md truncate" attr:title=url_clone>
{t.url.clone()}
</TableCell>
<TableCell class="text-center">{t_type}</TableCell>
<TableCell class="text-center">{is_enabled}</TableCell>
<TableCell class="text-center">{t.group}</TableCell>
<TableCell class="text-center">{t.seeders}</TableCell>
<TableCell class="text-center">{t.peers}</TableCell>
<TableCell class="text-center">{t.downloaded}</TableCell>
<TableCell class="text-center">{last_update_str}</TableCell>
<TableCell class="text-center">{format_duration_short(t.interval)}</TableCell>
<TableCell class="text-center">"bilinmiyor"</TableCell> // Özel flag isn't cleanly via XMLRPC per tracker usually
</TableRow>
}
}
/>
</TableBody>
</Table>
</TableWrapper>
</div>
}.into_any()
}}
</Suspense>
}
}
#[component]
fn TrackersFallback() -> impl IntoView {
view! {
<Shimmer loading=Signal::derive(|| true) class="space-y-2">
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
</Shimmer>
}
}
fn format_duration_short(seconds: i64) -> String {
if seconds <= 0 { return "0sn".to_string(); }
let days = seconds / 86400;
let hours = (seconds % 86400) / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
if days > 0 { format!("{}g {}s", days, hours) }
else if hours > 0 { format!("{}s {}dk", hours, minutes) }
else if minutes > 0 { format!("{}dk {}sn", minutes, secs) }
else { format!("{}sn", secs) }
}

View File

@@ -3,13 +3,17 @@ use leptos_ui::clx;
mod components {
use super::*;
clx! {Card, div, "bg-card text-card-foreground flex flex-col gap-4 rounded-xl border py-6 shadow-sm"}
clx! {CardHeader, div, "@container/card-header flex flex-col items-start gap-1.5 px-6 [.border-b]:pb-6"}
// TODO. Change data-slot=card-action by data-name="CardAction".
clx! {CardHeader, div, "@container/card-header flex flex-col items-start gap-1.5 px-6 [.border-b]:pb-6 sm:grid sm:auto-rows-min sm:grid-rows-[auto_auto] has-data-[slot=card-action]:sm:grid-cols-[1fr_auto]"}
clx! {CardTitle, h2, "leading-none font-semibold"}
clx! {CardContent, div, "px-6"}
clx! {CardDescription, p, "text-muted-foreground text-sm"}
clx! {CardFooter, footer, "flex items-center px-6 [.border-t]:pt-6", "gap-2"}
clx! {CardAction, div, "self-start sm:col-start-2 sm:row-span-2 sm:row-start-1 sm:justify-self-end"}
clx! {CardList, ul, "flex flex-col gap-4"}
clx! {CardItem, li, "flex items-center [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"}
}
pub use components::*;
pub use components::*;

View File

@@ -215,12 +215,20 @@ pub fn ContextMenuContent(
// Adjust if menu would go off right edge
if (x + menuRect.width > viewportWidth) {{
left = x - menuRect.width;
left = Math.max(0, x - menuRect.width);
}}
// Adjust if menu would go off bottom edge
if (y + menuRect.height > viewportHeight) {{
top = y - menuRect.height;
top = Math.max(0, y - menuRect.height);
}}
// Adjust for CSS transformed containing block
const offsetParent = menu.offsetParent;
if (offsetParent && offsetParent !== document.body && offsetParent !== document.documentElement) {{
const parentRect = offsetParent.getBoundingClientRect();
left -= parentRect.left;
top -= parentRect.top;
}}
menu.style.left = `${{left}}px`;
@@ -228,105 +236,152 @@ pub fn ContextMenuContent(
menu.style.transformOrigin = 'top left';
}};
const openMenu = (x, y) => {{
isOpen = true;
const openMenu = (x, y) => {{
isOpen = true;
// Close any other open context menus
const allMenus = document.querySelectorAll('[data-target="target__context"]');
allMenus.forEach(m => {{
if (m !== menu && m.getAttribute('data-state') === 'open') {{
m.setAttribute('data-state', 'closed');
m.style.pointerEvents = 'none';
// Close any other open context menus
const allMenus = document.querySelectorAll('[data-target="target__context"]');
allMenus.forEach(m => {{
if (m !== menu && m.getAttribute('data-state') === 'open') {{
m.setAttribute('data-state', 'closed');
m.style.pointerEvents = 'none';
}}
}});
menu.setAttribute('data-state', 'open');
menu.style.visibility = 'hidden';
menu.style.pointerEvents = 'auto';
// Force reflow
menu.offsetHeight;
updatePosition(x, y);
menu.style.visibility = 'visible';
// Lock scroll
if (window.ScrollLock) {{
window.ScrollLock.lock();
}}
setTimeout(() => {{
document.addEventListener('click', handleClickOutside);
document.addEventListener('contextmenu', handleContextOutside);
}}, 0);
}};
const closeMenu = () => {{
isOpen = false;
menu.setAttribute('data-state', 'closed');
menu.style.pointerEvents = 'none';
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('contextmenu', handleContextOutside);
// Dispatch custom event for Leptos to listen to
menu.dispatchEvent(new CustomEvent('contextmenuclose', {{ bubbles: false }}));
if (window.ScrollLock) {{
window.ScrollLock.unlock(200);
}}
}};
const handleClickOutside = (e) => {{
if (!menu.contains(e.target)) {{
closeMenu();
}}
}};
const handleContextOutside = (e) => {{
if (!trigger.contains(e.target)) {{
closeMenu();
}}
}};
// Right-click on trigger (desktop)
trigger.addEventListener('contextmenu', (e) => {{
e.preventDefault();
e.stopPropagation();
if (isOpen) {{
closeMenu();
}}
openMenu(e.clientX, e.clientY);
}});
// Long-press on trigger (mobile)
let touchTimer = null;
let touchStartX = 0;
let touchStartY = 0;
const LONG_PRESS_DURATION = 500;
const MOVE_THRESHOLD = 10;
trigger.addEventListener('touchstart', (e) => {{
const touch = e.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
touchTimer = setTimeout(() => {{
e.preventDefault();
if (isOpen) {{
closeMenu();
}}
openMenu(touchStartX, touchStartY);
}}, LONG_PRESS_DURATION);
}}, {{ passive: false }});
trigger.addEventListener('touchmove', (e) => {{
if (touchTimer) {{
const touch = e.touches[0];
const dx = Math.abs(touch.clientX - touchStartX);
const dy = Math.abs(touch.clientY - touchStartY);
if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) {{
clearTimeout(touchTimer);
touchTimer = null;
}}
}}
}});
menu.setAttribute('data-state', 'open');
menu.style.visibility = 'hidden';
menu.style.pointerEvents = 'auto';
// Force reflow
menu.offsetHeight;
updatePosition(x, y);
menu.style.visibility = 'visible';
// Lock scroll
if (window.ScrollLock) {{
window.ScrollLock.lock();
}}
setTimeout(() => {{
document.addEventListener('click', handleClickOutside);
document.addEventListener('contextmenu', handleContextOutside);
}}, 0);
}};
const closeMenu = () => {{
isOpen = false;
menu.setAttribute('data-state', 'closed');
menu.style.pointerEvents = 'none';
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('contextmenu', handleContextOutside);
// Dispatch custom event for Leptos to listen to
menu.dispatchEvent(new CustomEvent('contextmenuclose', {{ bubbles: false }}));
if (window.ScrollLock) {{
window.ScrollLock.unlock(200);
}}
}};
const handleClickOutside = (e) => {{
if (!menu.contains(e.target)) {{
closeMenu();
}}
}};
const handleContextOutside = (e) => {{
if (!trigger.contains(e.target)) {{
closeMenu();
}}
}};
// Right-click on trigger
trigger.addEventListener('contextmenu', (e) => {{
e.preventDefault();
e.stopPropagation();
if (isOpen) {{
closeMenu();
}}
openMenu(e.clientX, e.clientY);
}});
// Close when action is clicked
const actions = menu.querySelectorAll('[data-context-close]');
actions.forEach(action => {{
action.addEventListener('click', () => {{
closeMenu();
trigger.addEventListener('touchend', () => {{
if (touchTimer) {{
clearTimeout(touchTimer);
touchTimer = null;
}}
}});
}});
// Handle ESC key
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && isOpen) {{
e.preventDefault();
closeMenu();
}}
}});
}};
trigger.addEventListener('touchcancel', () => {{
if (touchTimer) {{
clearTimeout(touchTimer);
touchTimer = null;
}}
}});
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupContextMenu);
}} else {{
setupContextMenu();
}}
}})();
"#,
target_id_for_script,
target_id_for_script,
)}
</script>
// Close when action is clicked
const actions = menu.querySelectorAll('[data-context-close]');
actions.forEach(action => {{
action.addEventListener('click', () => {{
closeMenu();
}});
}});
// Handle ESC key
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && isOpen) {{
e.preventDefault();
closeMenu();
}}
}});
}};
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupContextMenu);
}} else {{
setupContextMenu();
}}
}})();
"#,
target_id_for_script,
target_id_for_script,
)}
</script>
}
}

View File

@@ -1,6 +1,6 @@
// * Reuse @table.rs
pub use crate::components::ui::table::{
Table as DataTable, TableBody as DataTableBody, TableCell as DataTableCell,
TableHead as DataTableHead, TableHeader as DataTableHeader,
Table as DataTable, TableBody as DataTableBody, TableCaption as DataTableCaption, TableCell as DataTableCell,
TableFooter as DataTableFooter, TableHead as DataTableHead, TableHeader as DataTableHeader,
TableRow as DataTableRow, TableWrapper as DataTableWrapper,
};

View File

@@ -5,7 +5,7 @@ use leptos_ui::clx;
use tw_merge::*;
use crate::components::hooks::use_random::use_random_id_for;
// pub use crate::components::ui::separator::Separator as DropdownMenuSeparator;
pub use crate::components::ui::separator::Separator as DropdownMenuSeparator;
mod components {
use super::*;

View File

@@ -14,11 +14,14 @@ pub mod input;
pub mod multi_select;
pub mod select;
pub mod separator;
pub mod scroll_area;
pub mod sheet;
pub mod sidenav;
pub mod skeleton;
pub mod shimmer;
pub mod svg_icon;
pub mod switch;
pub mod table;
pub mod tabs;
pub mod theme_toggle;
pub mod toast;

View File

@@ -9,7 +9,7 @@ use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
use crate::components::hooks::use_random::use_random_id_for;
// * Reuse @select.rs
pub use crate::components::ui::select::{
SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem,
SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem, SelectLabel as MultiSelectLabel,
};
#[derive(Clone, Copy, PartialEq, Eq, Default)]

View File

@@ -0,0 +1,102 @@
use leptos::prelude::*;
use leptos_ui::void;
use tw_merge::*;
// Removed unused fake components
/* ========================================================== */
/* ✨ COMPONENTS ✨ */
/* ========================================================== */
#[component]
pub fn ScrollArea(children: Children, #[prop(into, optional)] class: String) -> impl IntoView {
let merged_class = tw_merge!("relative overflow-hidden", class);
view! {
<div data-name="ScrollArea" class=merged_class>
<ScrollAreaViewport class="pr-3 pb-3 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-border/60 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-border/80">{children()}</ScrollAreaViewport>
</div>
}
}
#[component]
pub fn ScrollAreaViewport(children: Children, #[prop(into, optional)] class: String) -> impl IntoView {
let merged_class = tw_merge!(
"focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 overflow-auto",
class
);
view! {
<div data-name="ScrollAreaViewport" class=merged_class>
{children()}
</div>
}
}
/* ========================================================== */
/* 🧬 ENUMS 🧬 */
/* ========================================================== */
// Real scrollbars are now utilized in the viewport directly.
/* ========================================================== */
/* 🧬 STRUCT 🧬 */
/* ========================================================== */
#[component]
pub fn SnapScrollArea(
#[prop(into, default = SnapAreaVariant::default())] variant: SnapAreaVariant,
#[prop(into, optional)] class: String,
children: Children,
) -> impl IntoView {
let snap_item = SnapAreaClass { variant };
let merged_class = snap_item.with_class(class);
view! {
<div data-name="SnapScrollArea" class=merged_class>
{children()}
</div>
}
}
#[derive(TwClass, Default)]
#[tw(class = "")]
pub struct SnapAreaClass {
variant: SnapAreaVariant,
}
#[derive(TwVariant)]
pub enum SnapAreaVariant {
// * snap-x by default
#[tw(default, class = "overflow-x-auto snap-x")]
Center,
}
/* ========================================================== */
/* 🧬 STRUCT 🧬 */
/* ========================================================== */
#[component]
pub fn SnapItem(
#[prop(into, default = SnapVariant::default())] variant: SnapVariant,
#[prop(into, optional)] class: String,
children: Children,
) -> impl IntoView {
let snap_item = SnapItemClass { variant };
let merged_class = snap_item.with_class(class);
view! {
<div data-name="SnapItem" class=merged_class>
{children()}
</div>
}
}
#[derive(TwClass, Default)]
#[tw(class = "shrink-0")]
pub struct SnapItemClass {
variant: SnapVariant,
}
#[derive(TwVariant)]
pub enum SnapVariant {
// * snap-center by default
#[tw(default, class = "snap-center")]
Center,
}

View File

@@ -16,8 +16,6 @@ mod components {
clx! {SheetFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"}
}
// pub use components::*;
/* ========================================================== */
/* ✨ CONTEXT ✨ */
/* ========================================================== */

View File

@@ -0,0 +1,52 @@
use leptos::prelude::*;
use tw_merge::*;
use crate::components::hooks::use_random::use_random_id_for;
#[component]
pub fn Shimmer(
/// Controls shimmer visibility (works with any bool signal)
#[prop(into)]
loading: Signal<bool>,
/// Color of the shimmer wave (default: "rgba(255,255,255,0.15)")
#[prop(into, optional)]
shimmer_color: Option<String>,
/// Background color of shimmer blocks (default: "rgba(255,255,255,0.08)")
#[prop(into, optional)]
background_color: Option<String>,
/// Animation duration in seconds (default: 1.5)
#[prop(optional)]
duration: Option<f64>,
/// Fallback border-radius for text elements in px (default: 4)
#[prop(optional)]
fallback_border_radius: Option<f64>,
/// Additional classes
#[prop(into, optional)]
class: String,
/// Children to wrap
children: Children,
) -> impl IntoView {
let shimmer_id = use_random_id_for("Shimmer");
let merged_class = tw_merge!("relative", class);
view! {
<div
id=shimmer_id
class=merged_class
data-name="Shimmer"
data-shimmer-loading=move || loading.get().to_string()
data-shimmer-color=shimmer_color
data-shimmer-bg-color=background_color
data-shimmer-duration=duration.map(|d| d.to_string())
data-shimmer-fallback-radius=fallback_border_radius.map(|r| r.to_string())
>
{children()}
</div>
}
}

View File

@@ -145,8 +145,7 @@ pub fn Sidenav(
data-name="Sidenav"
data-sidenav=data_state.to_string()
data-side=data_side.to_string()
data-collapsible=data_collapsible.to_string()
class="hidden md:block group peer text-sidenav-foreground group-data-[collapsible=Offcanvas]:data-[state=Collapsed]:hidden"
class="hidden md:block group peer text-sidenav-foreground data-[state=Collapsed]:hidden"
>
// * SidenavGap: This is what handles the sidenav gap on desktop
<div
@@ -156,9 +155,9 @@ pub fn Sidenav(
"group-data-[collapsible=Offcanvas]:w-0",
"group-data-[side=Right]:rotate-180",
match variant {
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:group-data-[state=Collapsed]:w-(--sidenav-width-icon)",
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:w-(--sidenav-width-icon)",
SidenavVariant::Floating | SidenavVariant::Inset =>
"group-data-[collapsible=Icon]:group-data-[state=Collapsed]:w-[calc(var(--sidenav-width-icon)+(--spacing(4)))]",
"group-data-[collapsible=Icon]:w-[calc(var(--sidenav-width-icon)+(--spacing(4)))]",
}
)
/>
@@ -172,9 +171,9 @@ pub fn Sidenav(
SidenavSide::Right => "right-0 group-data-[collapsible=Offcanvas]:right-[calc(var(--sidenav-width)*-1)]"
},
match variant {
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:group-data-[state=Collapsed]:w-(--sidenav-width-icon) group-data-[side=Left]:border-r group-data-[side=Right]:border-l",
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:w-(--sidenav-width-icon) group-data-[side=Left]:border-r group-data-[side=Right]:border-l",
SidenavVariant::Floating | SidenavVariant::Inset =>
"p-2 group-data-[collapsible=Icon]:group-data-[state=Collapsed]:w-[calc(var(--sidenav-width-icon)+(--spacing(4))+2px)]",
"p-2 group-data-[collapsible=Icon]:w-[calc(var(--sidenav-width-icon)+(--spacing(4))+2px)]",
},
)
>

View File

@@ -0,0 +1,108 @@
use leptos::context::Provider;
use leptos::prelude::*;
use tw_merge::tw_merge;
#[derive(Clone)]
pub struct TabsContext {
pub active_tab: RwSignal<String>,
}
#[component]
pub fn Tabs(
#[prop(into)] default_value: String,
children: Children,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let active_tab = RwSignal::new(default_value);
let ctx = TabsContext { active_tab };
let merged_class = tw_merge!("w-full", &class);
view! {
<Provider value=ctx>
<div data-name="Tabs" class=merged_class>
{children()}
</div>
</Provider>
}
}
#[component]
pub fn TabsList(
children: Children,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let merged_class = tw_merge!(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
&class
);
view! {
<div data-name="TabsList" class=merged_class>
{children()}
</div>
}
}
#[component]
pub fn TabsTrigger(
#[prop(into)] value: String,
children: Children,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let ctx = expect_context::<TabsContext>();
let v_clone = value.clone();
let is_active = Memo::new(move |_| ctx.active_tab.get() == v_clone);
let merged_class = move || tw_merge!(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none",
if is_active.get() {
"bg-background text-foreground shadow-sm"
} else {
"hover:bg-background/50 hover:text-foreground"
},
&class
);
view! {
<button
data-name="TabsTrigger"
type="button"
class=merged_class
data-state=move || if is_active.get() { "active" } else { "inactive" }
on:click=move |_| ctx.active_tab.set(value.clone())
>
{children()}
</button>
}
}
#[component]
pub fn TabsContent(
#[prop(into)] value: String,
children: Children,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let ctx = expect_context::<TabsContext>();
let v_clone = value.clone();
let is_active = Memo::new(move |_| ctx.active_tab.get() == v_clone);
let merged_class = move || tw_merge!(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
if !is_active.get() { "hidden" } else { "" },
&class
);
view! {
<div
data-name="TabsContent"
class=merged_class
data-state=move || if is_active.get() { "active" } else { "inactive" }
tabindex=move || if is_active.get() { "0" } else { "-1" }
>
{children()}
</div>
}
}

View File

@@ -5,8 +5,7 @@ use leptos::task::spawn_local;
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
use std::collections::HashMap;
use struct_patch::traits::Patch;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use base64::{Engine as _, engine::general_purpose::{URL_SAFE_NO_PAD as BASE64_URL, STANDARD as BASE64}};
use wasm_bindgen::JsCast;
use crate::components::ui::toast::{ToastType, toast};

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = "vibetorrent-v2";
const CACHE_NAME = "vibetorrent-v3";
const ASSETS_TO_CACHE = [
"/",
"/index.html",
@@ -51,6 +51,11 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// Skip unsupported schemes (like chrome-extension://)
if (!url.protocol.startsWith("http")) {
return;
}
// Network-first strategy for API calls
if (url.pathname.startsWith("/api/")) {
event.respondWith(
@@ -75,10 +80,12 @@ self.addEventListener("fetch", (event) => {
fetch(event.request)
.then((response) => {
// Cache the latest version of the HTML
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
if (response && response.status === 200) {
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
}
return response;
})
.catch(() => {

View File

@@ -42,6 +42,11 @@ pub struct Torrent {
pub error_message: String,
pub added_date: i64,
pub label: Option<String>,
pub ratio: f64,
pub uploaded: i64,
pub wasted: i64,
pub save_path: String,
pub free_disk_space: i64,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
@@ -121,6 +126,13 @@ pub struct TorrentTracker {
pub url: String,
pub status: String,
pub message: String,
pub is_enabled: bool,
pub group: i64,
pub seeders: i64,
pub peers: i64,
pub downloaded: i64,
pub last_updated: i64,
pub interval: i64,
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]

View File

@@ -83,12 +83,19 @@ impl ScgiRequest {
pub async fn send_request(socket_path: &str, request: ScgiRequest) -> Result<Bytes, ScgiError> {
let perform_request = async {
let mut stream = UnixStream::connect(socket_path).await?;
let data = request.encode();
stream.write_all(&data).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
if socket_path.contains(':') {
let mut stream = tokio::net::TcpStream::connect(socket_path).await?;
stream.write_all(&data).await?;
stream.read_to_end(&mut response).await?;
} else {
let mut stream = tokio::net::UnixStream::connect(socket_path).await?;
stream.write_all(&data).await?;
stream.read_to_end(&mut response).await?;
}
Ok::<Vec<u8>, std::io::Error>(response)
};

View File

@@ -193,8 +193,12 @@ pub async fn get_trackers(hash: String) -> Result<Vec<TorrentTracker>, ServerFnE
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("t.url="),
RpcParam::from("t.activity_date_last="),
RpcParam::from("t.message="),
RpcParam::from("t.is_enabled="),
RpcParam::from("t.group="),
RpcParam::from("t.scrape_complete="),
RpcParam::from("t.scrape_incomplete="),
RpcParam::from("t.scrape_downloaded="),
RpcParam::from("t.normal_interval="),
];
let xml = client
@@ -205,14 +209,23 @@ pub async fn get_trackers(hash: String) -> Result<Vec<TorrentTracker>, ServerFnE
let rows = parse_multicall_response(&xml)
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
Ok(rows
let result: Vec<TorrentTracker> = 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(),
is_enabled: row.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(0) != 0,
group: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
seeders: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
peers: row.get(4).and_then(|s| s.parse().ok()).unwrap_or(0),
downloaded: row.get(5).and_then(|s| s.parse().ok()).unwrap_or(0),
interval: row.get(6).and_then(|s| s.parse().ok()).unwrap_or(0),
last_updated: 0,
status: "Unknown".to_string(), // Can derive from activity later, or keep unknown
message: "".to_string(),
})
.collect())
.collect();
Ok(result)
}
#[server(SetFilePriority, "/api/server_fns")]
@@ -225,6 +238,7 @@ pub async fn set_file_priority(
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
// rTorrent f.set_priority takes: target = "HASH:fINDEX", value = priority
let target = format!("{}:f{}", hash, file_index);
let params = vec![
RpcParam::from(target.as_str()),
@@ -232,10 +246,11 @@ pub async fn set_file_priority(
];
client
.call("f.set_priority", &params)
.call("f.priority.set", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
.map_err(|e| ServerFnError::new(format!("RPC error setting priority: {}", e)))?;
// Notify rTorrent to update its internal priority state
let _ = client
.call("d.update_priorities", &[RpcParam::from(hash.as_str())])
.await;

26
test_rpc.rs Normal file
View File

@@ -0,0 +1,26 @@
use shared::xmlrpc::{RtorrentClient, RpcParam, parse_multicall_response};
#[tokio::main]
async fn main() {
let client = RtorrentClient::new("/tmp/rtorrent.sock");
// Hardcode a known hash from the UI, e.g. "C3315ABFAD70C54505813D1303C1457900C5B795" (from first image)
let hash = "C3315ABFAD70C54505813D1303C1457900C5B795";
let params = vec![
RpcParam::from(hash),
RpcParam::from(""),
RpcParam::from("t.url="),
];
match client.call("t.multicall", &params).await {
Ok(xml) => {
println!("Response XML:\n{}", xml);
match parse_multicall_response(&xml) {
Ok(rows) => println!("Rows ({})", rows.len()),
Err(e) => println!("Parse error: {:?}", e),
}
},
Err(e) => println!("Error: {:?}", e),
}
}