Compare commits
22 Commits
release-20
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71456ff4d1 | ||
|
|
1a3099d926 | ||
|
|
4ef4ee8d45 | ||
|
|
253067b417 | ||
|
|
8d5edc659f | ||
|
|
c122290f37 | ||
|
|
999cef34a7 | ||
|
|
93a43d1b38 | ||
|
|
91ca6ff96f | ||
|
|
8c0d35cca5 | ||
|
|
444ea4326d | ||
|
|
401ccb69b2 | ||
|
|
9cfea2aed5 | ||
|
|
ce212cb2d6 | ||
|
|
851d79029a | ||
|
|
ab27cf3eb4 | ||
|
|
7b4c9ff336 | ||
|
|
743596d701 | ||
|
|
598f038ea6 | ||
|
|
7f8c721115 | ||
|
|
ba7f1ffd91 | ||
|
|
daa24dd7ec |
83
backend/src/bin/test_trackers.rs
Normal file
83
backend/src/bin/test_trackers.rs
Normal 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", ¶ms).await {
|
||||||
|
Ok(xml) => {
|
||||||
|
if xml.contains("faultCode") {
|
||||||
|
println!("FAILED");
|
||||||
|
} else {
|
||||||
|
println!("SUCCESS");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => println!("ERROR: {:?}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
121
frontend/src/components/torrent/trackers.rs
Normal file
121
frontend/src/components/torrent/trackers.rs
Normal 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) }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
102
frontend/src/components/ui/scroll_area.rs
Normal file
102
frontend/src/components/ui/scroll_area.rs
Normal 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,
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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", ¶ms)
|
.call("f.priority.set", ¶ms)
|
||||||
.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
26
test_rpc.rs
Normal 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", ¶ms).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),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user