Compare commits

...

22 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
18 changed files with 4088 additions and 3818 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

@@ -51,6 +51,21 @@ mod fields {
pub const IDX_LABEL: usize = 12; pub const IDX_LABEL: usize = 12;
pub const CMD_LABEL: &str = "d.custom1="; 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::*; use fields::*;
@@ -72,6 +87,11 @@ const RTORRENT_FIELDS: &[&str] = &[
CMD_CREATION_DATE, CMD_CREATION_DATE,
CMD_HASHING, CMD_HASHING,
CMD_LABEL, CMD_LABEL,
CMD_RATIO,
CMD_UPLOADED,
CMD_WASTED,
CMD_SAVE_PATH,
CMD_FREE_DISK,
]; ];
fn parse_long(s: Option<&String>) -> i64 { 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 added_date = parse_long(row.get(IDX_CREATION_DATE));
let is_hashing = parse_long(row.get(IDX_HASHING)); let is_hashing = parse_long(row.get(IDX_HASHING));
let label_raw = parse_string(row.get(IDX_LABEL)); 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() { let label = if label_raw.is_empty() {
None None
@@ -144,6 +169,11 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
error_message: message, error_message: message,
added_date, added_date,
label, label,
ratio,
uploaded,
wasted,
save_path,
free_disk_space,
} }
} }

View File

@@ -7,6 +7,11 @@ stage = "build"
command = "sh" command = "sh"
command_arguments = ["-c", "npx @tailwindcss/cli -i input.css -o public/tailwind.css"] 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] [build]
target = "index.html" target = "index.html"
dist = "dist" dist = "dist"

View File

@@ -20,7 +20,7 @@
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" /> <link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<!-- Trunk Assets --> <!-- 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="css" href="public/tailwind.css" />
<link data-trunk rel="copy-file" href="manifest.json" /> <link data-trunk rel="copy-file" href="manifest.json" />
<link data-trunk rel="copy-file" href="icon-192.png" /> <link data-trunk rel="copy-file" href="icon-192.png" />

File diff suppressed because it is too large Load Diff

View File

@@ -49,14 +49,10 @@ fn InnerApp() -> impl IntoView {
Effect::new(move |_| { Effect::new(move |_| {
spawn_local(async 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 // Check if setup is needed via Server Function
match shared::server_fns::auth::get_setup_status().await { match shared::server_fns::auth::get_setup_status().await {
Ok(status) => { Ok(status) => {
if !status.completed { if !status.completed {
log::info!("Setup not completed");
needs_setup.1.set(true); needs_setup.1.set(true);
is_loading.1.set(false); is_loading.1.set(false);
return; return;
@@ -68,15 +64,12 @@ fn InnerApp() -> impl IntoView {
// Check authentication via GetUser Server Function // Check authentication via GetUser Server Function
match shared::server_fns::auth::get_user().await { match shared::server_fns::auth::get_user().await {
Ok(Some(user_info)) => { Ok(Some(user_info)) => {
log::info!("Authenticated as {}", user_info.username);
if let Some(s) = store { if let Some(s) = store {
s.user.set(Some(user_info.username)); s.user.set(Some(user_info.username));
} }
is_authenticated.1.set(true); is_authenticated.1.set(true);
} }
Ok(None) => { Ok(None) => {}
log::info!("Not authenticated");
}
Err(e) => { Err(e) => {
log::error!("Auth check failed: {:?}", e); log::error!("Auth check failed: {:?}", e);
} }
@@ -111,7 +104,6 @@ fn InnerApp() -> impl IntoView {
let navigate = use_navigate(); let navigate = use_navigate();
navigate("/setup", Default::default()); navigate("/setup", Default::default());
} else if authenticated { } else if authenticated {
log::info!("Already authenticated, redirecting to home");
let navigate = use_navigate(); let navigate = use_navigate();
navigate("/", Default::default()); navigate("/", Default::default());
} }
@@ -135,10 +127,8 @@ fn InnerApp() -> impl IntoView {
Effect::new(move |_| { Effect::new(move |_| {
if !is_loading.0.get() { if !is_loading.0.get() {
if needs_setup.0.get() { if needs_setup.0.get() {
log::info!("Setup not completed, redirecting to setup");
navigate("/setup", Default::default()); navigate("/setup", Default::default());
} else if !is_authenticated.0.get() { } else if !is_authenticated.0.get() {
log::info!("Not authenticated, redirecting to login");
navigate("/login", Default::default()); navigate("/login", Default::default());
} }
} }

View File

@@ -1,76 +1,115 @@
use leptos::prelude::*; use leptos::prelude::*;
use crate::components::ui::sheet::*;
use crate::components::ui::tabs::*; use crate::components::ui::tabs::*;
use crate::components::ui::skeleton::*; use crate::components::ui::skeleton::*;
use shared::Torrent;
#[component] #[component]
pub fn TorrentDetailsSheet() -> impl IntoView { pub fn TorrentDetailsPanel() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
// Setup an effect to open the sheet when a torrent is selected
Effect::new(move |_| {
if store.selected_torrent.get().is_some() {
if let Some(trigger) = document().get_element_by_id("torrent-details-trigger") {
use wasm_bindgen::JsCast;
let _ = trigger.dyn_into::<web_sys::HtmlElement>().map(|el| el.click());
}
}
});
let selected_torrent = Memo::new(move |_| { let selected_torrent = Memo::new(move |_| {
let hash = store.selected_torrent.get()?; let hash = store.selected_torrent.get()?;
store.torrents.with(|map| map.get(&hash).cloned()) store.torrents.with(|map| map.get(&hash).cloned())
}); });
let is_open = Signal::derive(move || store.selected_torrent.get().is_some());
view! { view! {
<Sheet> // Mobil overlay backdrop
<SheetTrigger attr:id="torrent-details-trigger" class="hidden">""</SheetTrigger> <div
<SheetContent class=move || if is_open.get() {
direction=SheetDirection::Bottom "fixed inset-0 bg-black/40 z-30 md:hidden backdrop-blur-sm transition-opacity duration-300 opacity-100"
class="h-[80vh] sm:h-[60vh] rounded-t-xl sm:rounded-t-2xl p-0 flex flex-col gap-0 border-t border-border shadow-2xl" } else {
hide_close_button=true "fixed inset-0 bg-black/0 z-30 md:hidden pointer-events-none transition-opacity duration-300 opacity-0"
> }
<div class="px-6 py-4 border-b flex items-center justify-between sticky top-0 bg-card z-10"> on:click=move |_| store.selected_torrent.set(None)
<div class="flex flex-col gap-1 min-w-0"> />
<Show when=move || selected_torrent.get().is_some() fallback=move || view! { <Skeleton class="h-6 w-48" /> }>
<h2 class="font-bold text-lg truncate"> // 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()} {move || selected_torrent.get().map(|t| t.name).unwrap_or_default()}
</h2> </h2>
</Show> </Show>
<Show when=move || selected_torrent.get().is_some() fallback=move || view! { <Skeleton class="h-4 w-24" /> }> <Show
<p class="text-xs text-muted-foreground uppercase tracking-widest font-semibold flex items-center gap-2"> 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()} {move || selected_torrent.get().map(|t| format!("{:?}", t.status)).unwrap_or_default()}
<span class="bg-primary/20 text-primary px-1.5 py-0.5 rounded text-[10px] lowercase">{move || selected_torrent.get().map(|t| format!("{:.1}%", t.percent_complete)).unwrap_or_default()}</span> <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> </p>
</Show> </Show>
</div> </div>
// Custom close button that also resets store.selected_torrent // Kapat butonu
<SheetClose class="rounded-full p-2 hover:bg-muted transition-colors border-none shadow-none cursor-pointer"> <button
<icons::X class="size-5 opacity-70" on:click=move |_| store.selected_torrent.set(None) /> class="rounded-full p-1.5 hover:bg-muted transition-colors text-muted-foreground hover:text-foreground shrink-0 ml-2"
</SheetClose> on:click=move |_| store.selected_torrent.set(None)
>
<icons::X class="size-4" />
</button>
</div> </div>
<div class="flex-1 overflow-hidden p-6"> // Sekmeler + içerik
<Tabs default_value="general" class="h-full flex flex-col"> <div class="flex-1 overflow-hidden flex flex-col min-h-0">
<TabsList class="w-full justify-start rounded-none border-b bg-transparent p-0"> <Tabs default_value="general" class="flex-1 h-full min-h-0 flex flex-col">
<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"> <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" "Genel"
</TabsTrigger> </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"> <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" "Dosyalar"
</TabsTrigger> </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"> <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" "İzleyiciler"
</TabsTrigger> </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"> <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" "Eşler"
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<div class="flex-1 overflow-y-auto mt-4 pb-12"> <crate::components::ui::scroll_area::ScrollArea class="flex-1 min-h-0">
<TabsContent value="general" class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300"> <TabsContent value="general" class="p-4 space-y-5 animate-in fade-in duration-200">
<crate::components::ui::shimmer::Shimmer <crate::components::ui::shimmer::Shimmer
loading=Signal::derive(move || selected_torrent.get().is_none()) loading=Signal::derive(move || selected_torrent.get().is_none())
shimmer_color="rgba(0,0,0,0.06)" shimmer_color="rgba(0,0,0,0.06)"
background_color="rgba(0,0,0,0.04)" background_color="rgba(0,0,0,0.04)"
@@ -89,16 +128,41 @@ pub fn TorrentDetailsSheet() -> impl IntoView {
error_message: "".to_string(), error_message: "".to_string(),
added_date: 0, added_date: 0,
label: None, label: None,
ratio: 0.0,
uploaded: 0,
wasted: 0,
save_path: "Yükleniyor...".to_string(),
free_disk_space: 0,
}); });
view! { view! {
<div class="grid grid-cols-2 md:grid-cols-4 gap-6"> <div class="flex flex-col gap-5">
<InfoItem label="İndirilen / Toplam" value=format!("{} / {}", format_bytes(t.completed), format_bytes(t.size)) /> // Aktarım
<InfoItem label="İndirme Hızı" value=format_speed(t.down_rate) class="text-blue-500" /> <div>
<InfoItem label="Gönderme Hızı" value=format_speed(t.up_rate) class="text-green-500" /> <h3 class="text-[10px] font-bold border-b pb-1.5 mb-3 uppercase tracking-widest text-muted-foreground">"Aktarım"</h3>
<InfoItem label="Eklenme Tarihi" value=format_date(t.added_date) /> <div class="grid grid-cols-2 gap-3">
<InfoItem label="Kalan Süre" value=format_duration(t.eta) /> <InfoItem label="Kalan" value=format_duration(t.eta) />
<InfoItem label="Hash" value=t.hash class="col-span-2 md:col-span-4 break-all font-mono text-xs" /> <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> </div>
} }
}} }}
@@ -108,13 +172,13 @@ pub fn TorrentDetailsSheet() -> impl IntoView {
<TabsContent value="files" class="h-full"> <TabsContent value="files" class="h-full">
{move || match selected_torrent.get() { {move || match selected_torrent.get() {
Some(t) => leptos::either::Either::Left(view! { Some(t) => leptos::either::Either::Left(view! {
<div class="h-full overflow-y-auto pr-2 pb-8"> <div class="h-full">
<crate::components::torrent::files::TorrentFilesTab hash=t.hash /> <crate::components::torrent::files::TorrentFilesTab hash=t.hash />
</div> </div>
}), }),
None => leptos::either::Either::Right(view! { None => leptos::either::Either::Right(view! {
<div class="flex flex-col items-center justify-center h-48 opacity-60"> <div class="flex flex-col items-center justify-center h-48 opacity-60 gap-2">
<icons::File class="size-12 mb-3 text-muted-foreground" /> <icons::File class="size-10 text-muted-foreground" />
<p class="text-sm font-medium">"Dosya yükleniyor..."</p> <p class="text-sm font-medium">"Dosya yükleniyor..."</p>
</div> </div>
}), }),
@@ -122,36 +186,45 @@ pub fn TorrentDetailsSheet() -> impl IntoView {
</TabsContent> </TabsContent>
<TabsContent value="trackers" class="h-full"> <TabsContent value="trackers" class="h-full">
<div class="flex flex-col items-center justify-center h-48 opacity-60"> {move || match selected_torrent.get() {
<icons::Settings2 class="size-12 mb-3 text-muted-foreground" /> Some(t) => leptos::either::Either::Left(view! {
<p class="text-sm font-medium">"İzleyici listesi yakında eklenecek"</p> <div class="h-full">
</div> <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>
<TabsContent value="peers" class="h-full"> <TabsContent value="peers" class="h-full">
<div class="flex flex-col items-center justify-center h-48 opacity-60"> <div class="flex flex-col items-center justify-center h-48 opacity-60 gap-2">
<icons::Users class="size-12 mb-3 text-muted-foreground" /> <icons::Users class="size-10 text-muted-foreground" />
<p class="text-sm font-medium">"Eş listesi yakında eklenecek"</p> <p class="text-sm font-medium">"Eş listesi yakında eklenecek"</p>
</div> </div>
</TabsContent> </TabsContent>
</div> </crate::components::ui::scroll_area::ScrollArea>
</Tabs> </Tabs>
</div> </div>
</SheetContent> </Show>
</Sheet> </div>
} }
} }
#[component] #[component]
fn InfoItem( fn InfoItem(
label: &'static str, label: &'static str,
value: String, value: String,
#[prop(optional)] class: &'static str #[prop(optional)] class: &'static str
) -> impl IntoView { ) -> impl IntoView {
view! { view! {
<div class=tailwind_fuse::tw_merge!("flex flex-col gap-1", class)> <div class=tailwind_fuse::tw_merge!("flex flex-col gap-0.5", class)>
<span class="text-xs font-semibold text-muted-foreground uppercase opacity-80">{label}</span> <span class="text-[9px] font-semibold text-muted-foreground uppercase tracking-wider opacity-70">{label}</span>
<span class="text-sm font-medium">{value}</span> <span class="text-xs font-medium leading-tight">{value}</span>
</div> </div>
} }
} }

View File

@@ -15,10 +15,9 @@ pub fn TorrentFilesTab(hash: String) -> impl IntoView {
|h| async move { shared::server_fns::torrent::get_files(h).await.unwrap_or_default() } |h| async move { shared::server_fns::torrent::get_files(h).await.unwrap_or_default() }
); );
// Refresh action // Callback to trigger a refetch — safe, doesn't destroy existing components
let refresh_files = Action::new(|h: &String| { let on_refresh = Callback::new(move |_: ()| {
let h = h.clone(); files_resource.refetch();
async move { shared::server_fns::torrent::get_files(h).await.unwrap_or_default() }
}); });
let stored_hash = StoredValue::new(hash); let stored_hash = StoredValue::new(hash);
@@ -26,10 +25,7 @@ pub fn TorrentFilesTab(hash: String) -> impl IntoView {
view! { view! {
<Suspense fallback=move || view! { <FilesFallback /> }> <Suspense fallback=move || view! { <FilesFallback /> }>
{move || { {move || {
let files = match refresh_files.value().get() { let files = files_resource.get().unwrap_or_default();
Some(f) => f,
None => files_resource.get().unwrap_or_default(),
};
if files.is_empty() { if files.is_empty() {
return view! { return view! {
@@ -61,12 +57,11 @@ pub fn TorrentFilesTab(hash: String) -> impl IntoView {
key=|f| f.index key=|f| f.index
children={move |f| { children={move |f| {
let p_hash = stored_hash.get_value(); let p_hash = stored_hash.get_value();
let r_files = refresh_files.clone();
view! { view! {
<FileRow <FileRow
file=f file=f
hash=p_hash hash=p_hash
refresh_action=r_files on_refresh=on_refresh.clone()
/> />
} }
}} }}
@@ -86,12 +81,14 @@ pub fn TorrentFilesTab(hash: String) -> impl IntoView {
} }
#[component] #[component]
fn FileRow(file: TorrentFile, hash: String, refresh_action: Action<String, Vec<TorrentFile>>) -> impl IntoView { fn FileRow(file: TorrentFile, hash: String, on_refresh: Callback<()>) -> impl IntoView {
let f_idx = file.index; let f_idx = file.index;
let context_id = format!("file-context-{}-{}", hash, f_idx);
let path_clone = file.path.clone(); let path_clone = file.path.clone();
let set_priority = Action::new(|req: &(String, u32, u8)| { // 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(); let (h, idx, p) = req.clone();
async move { async move {
let res = shared::server_fns::torrent::set_file_priority(h, idx, p).await; let res = shared::server_fns::torrent::set_file_priority(h, idx, p).await;
@@ -99,47 +96,71 @@ fn FileRow(file: TorrentFile, hash: String, refresh_action: Action<String, Vec<T
crate::store::show_toast(shared::NotificationLevel::Error, format!("Öncelik değiştirilemedi: {:?}", e)); crate::store::show_toast(shared::NotificationLevel::Error, format!("Öncelik değiştirilemedi: {:?}", e));
} else { } else {
crate::store::show_toast(shared::NotificationLevel::Success, "Dosya önceliği güncellendi.".to_string()); 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 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! { view! {
<ContextMenu> <ContextMenu>
<ContextMenuTrigger attr:id=context_id.clone()> <ContextMenuTrigger>
<TableRow class="hover:bg-muted/50 transition-colors group"> {children()}
<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>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent class="w-48"> <ContextMenuContent class="w-48">
<ContextMenuLabel>"Dosya Önceliği"</ContextMenuLabel> <ContextMenuLabel>"Dosya Önceliği"</ContextMenuLabel>
<ContextMenuGroup> <ContextMenuGroup>
<ContextMenuItem on:click={ <ContextMenuItem on:click={
let h = hash.clone(); let h = hash_c1;
let ra = refresh_action.clone(); let sp = set_priority.clone();
move |_| { move |_| {
set_priority.dispatch((h.clone(), f_idx, 2)); sp.dispatch((h.clone(), file_index, 2));
ra.dispatch(h.clone()); crate::components::ui::context_menu::close_context_menu();
} }
}> }>
<icons::ChevronsUp class="text-green-500" /> <icons::ChevronsUp class="text-green-500" />
@@ -147,11 +168,11 @@ fn FileRow(file: TorrentFile, hash: String, refresh_action: Action<String, Vec<T
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem on:click={ <ContextMenuItem on:click={
let h = hash.clone(); let h = hash_c2;
let ra = refresh_action.clone(); let sp = set_priority.clone();
move |_| { move |_| {
set_priority.dispatch((h.clone(), f_idx, 1)); sp.dispatch((h.clone(), file_index, 1));
ra.dispatch(h.clone()); crate::components::ui::context_menu::close_context_menu();
} }
}> }>
<icons::Minus class="text-blue-500" /> <icons::Minus class="text-blue-500" />
@@ -159,11 +180,11 @@ fn FileRow(file: TorrentFile, hash: String, refresh_action: Action<String, Vec<T
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem class="text-destructive focus:bg-destructive/10" on:click={ <ContextMenuItem class="text-destructive focus:bg-destructive/10" on:click={
let h = hash.clone(); let h = hash_c3;
let ra = refresh_action.clone(); let sp = set_priority.clone();
move |_| { move |_| {
set_priority.dispatch((h.clone(), f_idx, 0)); sp.dispatch((h.clone(), file_index, 0));
ra.dispatch(h.clone()); crate::components::ui::context_menu::close_context_menu();
} }
}> }>
<icons::X /> <icons::X />

View File

@@ -2,3 +2,4 @@ pub mod table;
pub mod add_torrent; pub mod add_torrent;
pub mod details; pub mod details;
pub mod files; pub mod files;
pub mod trackers;

View File

@@ -218,7 +218,9 @@ pub fn TorrentTable() -> impl IntoView {
}); });
view! { 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 --- // --- TOPBAR ---
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2 flex-1 max-w-md"> <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" /> <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 |_| { <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") { 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()); let _ = trigger.dyn_into::<web_sys::HtmlElement>().map(|el: web_sys::HtmlElement| el.click());
@@ -261,7 +262,6 @@ pub fn TorrentTable() -> impl IntoView {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
// Hidden AlertDialog moved outside the DropdownMenuContent to ensure proper centering
<AlertDialog> <AlertDialog>
<AlertDialogTrigger attr:id="bulk-delete-trigger" class="hidden">""</AlertDialogTrigger> <AlertDialogTrigger attr:id="bulk-delete-trigger" class="hidden">""</AlertDialogTrigger>
<AlertDialogContent class="sm:max-w-[425px]"> <AlertDialogContent class="sm:max-w-[425px]">
@@ -553,9 +553,11 @@ pub fn TorrentTable() -> impl IntoView {
</div> </div>
<div class="opacity-50">"VibeTorrent v3"</div> <div class="opacity-50">"VibeTorrent v3"</div>
</div> </div>
<crate::components::torrent::details::TorrentDetailsSheet />
</div> </div>
// Sağ: sabit detay paneli
<crate::components::torrent::details::TorrentDetailsPanel />
</div>
}.into_any() }.into_any()
} }

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

@@ -215,12 +215,20 @@ pub fn ContextMenuContent(
// Adjust if menu would go off right edge // Adjust if menu would go off right edge
if (x + menuRect.width > viewportWidth) {{ if (x + menuRect.width > viewportWidth) {{
left = x - menuRect.width; left = Math.max(0, x - menuRect.width);
}} }}
// Adjust if menu would go off bottom edge // Adjust if menu would go off bottom edge
if (y + menuRect.height > viewportHeight) {{ 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`; menu.style.left = `${{left}}px`;
@@ -228,105 +236,152 @@ pub fn ContextMenuContent(
menu.style.transformOrigin = 'top left'; menu.style.transformOrigin = 'top left';
}}; }};
const openMenu = (x, y) => {{ const openMenu = (x, y) => {{
isOpen = true; isOpen = true;
// Close any other open context menus // Close any other open context menus
const allMenus = document.querySelectorAll('[data-target="target__context"]'); const allMenus = document.querySelectorAll('[data-target="target__context"]');
allMenus.forEach(m => {{ allMenus.forEach(m => {{
if (m !== menu && m.getAttribute('data-state') === 'open') {{ if (m !== menu && m.getAttribute('data-state') === 'open') {{
m.setAttribute('data-state', 'closed'); m.setAttribute('data-state', 'closed');
m.style.pointerEvents = 'none'; 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'); trigger.addEventListener('touchend', () => {{
menu.style.visibility = 'hidden'; if (touchTimer) {{
menu.style.pointerEvents = 'auto'; clearTimeout(touchTimer);
touchTimer = null;
// 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();
}}); }});
}});
// Handle ESC key trigger.addEventListener('touchcancel', () => {{
document.addEventListener('keydown', (e) => {{ if (touchTimer) {{
if (e.key === 'Escape' && isOpen) {{ clearTimeout(touchTimer);
e.preventDefault(); touchTimer = null;
closeMenu(); }}
}} }});
}});
}};
if (document.readyState === 'loading') {{ // Close when action is clicked
document.addEventListener('DOMContentLoaded', setupContextMenu); const actions = menu.querySelectorAll('[data-context-close]');
}} else {{ actions.forEach(action => {{
setupContextMenu(); action.addEventListener('click', () => {{
}} closeMenu();
}})(); }});
"#, }});
target_id_for_script,
target_id_for_script, // Handle ESC key
)} document.addEventListener('keydown', (e) => {{
</script> 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

@@ -14,6 +14,7 @@ pub mod input;
pub mod multi_select; pub mod multi_select;
pub mod select; pub mod select;
pub mod separator; pub mod separator;
pub mod scroll_area;
pub mod sheet; pub mod sheet;
pub mod sidenav; pub mod sidenav;
pub mod skeleton; pub mod skeleton;

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

@@ -42,6 +42,11 @@ pub struct Torrent {
pub error_message: String, pub error_message: String,
pub added_date: i64, pub added_date: i64,
pub label: Option<String>, 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)] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
@@ -121,6 +126,13 @@ pub struct TorrentTracker {
pub url: String, pub url: String,
pub status: String, pub status: String,
pub message: 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)] #[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> { pub async fn send_request(socket_path: &str, request: ScgiRequest) -> Result<Bytes, ScgiError> {
let perform_request = async { let perform_request = async {
let mut stream = UnixStream::connect(socket_path).await?;
let data = request.encode(); let data = request.encode();
stream.write_all(&data).await?;
let mut response = Vec::new(); 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) 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(hash.as_str()),
RpcParam::from(""), RpcParam::from(""),
RpcParam::from("t.url="), RpcParam::from("t.url="),
RpcParam::from("t.activity_date_last="), RpcParam::from("t.is_enabled="),
RpcParam::from("t.message="), 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 let xml = client
@@ -205,14 +209,23 @@ pub async fn get_trackers(hash: String) -> Result<Vec<TorrentTracker>, ServerFnE
let rows = parse_multicall_response(&xml) let rows = parse_multicall_response(&xml)
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?; .map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
Ok(rows let result: Vec<TorrentTracker> = rows
.into_iter() .into_iter()
.map(|row| TorrentTracker { .map(|row| TorrentTracker {
url: row.get(0).cloned().unwrap_or_default(), url: row.get(0).cloned().unwrap_or_default(),
status: "Unknown".to_string(), is_enabled: row.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(0) != 0,
message: row.get(2).cloned().unwrap_or_default(), 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")] #[server(SetFilePriority, "/api/server_fns")]
@@ -225,6 +238,7 @@ pub async fn set_file_priority(
let ctx = expect_context::<crate::ServerContext>(); let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path); 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 target = format!("{}:f{}", hash, file_index);
let params = vec![ let params = vec![
RpcParam::from(target.as_str()), RpcParam::from(target.as_str()),
@@ -232,10 +246,11 @@ pub async fn set_file_priority(
]; ];
client client
.call("f.set_priority", &params) .call("f.priority.set", &params)
.await .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 let _ = client
.call("d.update_priorities", &[RpcParam::from(hash.as_str())]) .call("d.update_priorities", &[RpcParam::from(hash.as_str())])
.await; .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),
}
}