Compare commits
12 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce212cb2d6 | ||
|
|
851d79029a | ||
|
|
ab27cf3eb4 | ||
|
|
7b4c9ff336 | ||
|
|
743596d701 | ||
|
|
598f038ea6 | ||
|
|
7f8c721115 | ||
|
|
ba7f1ffd91 | ||
|
|
daa24dd7ec | ||
|
|
45271b5060 | ||
|
|
4d02bc655d | ||
|
|
3c2ba477f5 |
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,16 +89,59 @@ 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-6">
|
||||||
<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-sm font-bold border-b pb-2 mb-4">"Aktarım"</h3>
|
||||||
<InfoItem label="Eklenme Tarihi" value=format_date(t.added_date) />
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<InfoItem label="Kalan Süre" value=format_duration(t.eta) />
|
<InfoItem label="Geçen Süre" value="N/A".to_string() />
|
||||||
<InfoItem label="Hash" value=t.hash class="col-span-2 md:col-span-4 break-all font-mono text-xs" />
|
<InfoItem label="Kalan" value=format_duration(t.eta) />
|
||||||
|
<InfoItem label="Paylaşım Oranı" value=format!("{:.3}", t.ratio) />
|
||||||
|
<div class="hidden md:block"></div>
|
||||||
|
|
||||||
|
<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="Boşa Giden" value=format_bytes(t.wasted) />
|
||||||
|
<div class="hidden md:block"></div>
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
<div class="hidden md:block"></div>
|
||||||
|
<div class="hidden md:block"></div>
|
||||||
|
|
||||||
|
<InfoItem label="Ortaklar" value="0 / 0 bağlı".to_string() />
|
||||||
|
<InfoItem label="Eşler" value="0 / 0 bağlı".to_string() />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// İzleyici
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-bold border-b pb-2 mb-4">"İzleyici"</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<InfoItem label="İzleyici URL'si" value="İzleyiciler sekmesine bakınız".to_string() class="col-span-1 md:col-span-2 break-all text-xs" />
|
||||||
|
<InfoItem label="İzleyici Durumu" value="N/A".to_string() />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Genel
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-bold border-b pb-2 mb-4">"Genel"</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<InfoItem label="Kaydedilen Yer" value=t.save_path class="col-span-1 md:col-span-2 break-all font-mono text-xs" />
|
||||||
|
<InfoItem label="Boş Disk Alanı" value=format_bytes(t.free_disk_space) />
|
||||||
|
<InfoItem label="Oluşturulma Tarihi" value=format_date(t.added_date) />
|
||||||
|
<InfoItem label="Hash" value=t.hash class="col-span-1 md:col-span-2 break-all font-mono text-xs" />
|
||||||
|
<InfoItem label="Yorum" value="Yok".to_string() class="col-span-1 md:col-span-2 break-words text-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -106,10 +149,19 @@ pub fn TorrentDetailsSheet() -> impl IntoView {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="files" class="h-full">
|
<TabsContent value="files" class="h-full">
|
||||||
<div class="flex flex-col items-center justify-center h-48 opacity-60">
|
{move || match selected_torrent.get() {
|
||||||
<icons::File class="size-12 mb-3 text-muted-foreground" />
|
Some(t) => leptos::either::Either::Left(view! {
|
||||||
<p class="text-sm font-medium">"Dosya listesi yakında eklenecek"</p>
|
<div class="h-full overflow-y-auto pr-2 pb-8">
|
||||||
</div>
|
<crate::components::torrent::files::TorrentFilesTab hash=t.hash />
|
||||||
|
</div>
|
||||||
|
}),
|
||||||
|
None => leptos::either::Either::Right(view! {
|
||||||
|
<div class="flex flex-col items-center justify-center h-48 opacity-60">
|
||||||
|
<icons::File class="size-12 mb-3 text-muted-foreground" />
|
||||||
|
<p class="text-sm font-medium">"Dosya yükleniyor..."</p>
|
||||||
|
</div>
|
||||||
|
}),
|
||||||
|
}}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="trackers" class="h-full">
|
<TabsContent value="trackers" class="h-full">
|
||||||
|
|||||||
216
frontend/src/components/torrent/files.rs
Normal file
216
frontend/src/components/torrent/files.rs
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use crate::components::ui::table::*;
|
||||||
|
use crate::components::ui::badge::*;
|
||||||
|
use crate::components::ui::shimmer::*;
|
||||||
|
use crate::components::ui::context_menu::*;
|
||||||
|
use shared::TorrentFile;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TorrentFilesTab(hash: String) -> impl IntoView {
|
||||||
|
let hash_clone = hash.clone();
|
||||||
|
|
||||||
|
// Fetch files resource
|
||||||
|
let files_resource = Resource::new(
|
||||||
|
move || hash_clone.clone(),
|
||||||
|
|h| async move { shared::server_fns::torrent::get_files(h).await.unwrap_or_default() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Callback to trigger a refetch — safe, doesn't destroy existing components
|
||||||
|
let on_refresh = Callback::new(move |_: ()| {
|
||||||
|
files_resource.refetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
let stored_hash = StoredValue::new(hash);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Suspense fallback=move || view! { <FilesFallback /> }>
|
||||||
|
{move || {
|
||||||
|
let files = files_resource.get().unwrap_or_default();
|
||||||
|
|
||||||
|
if files.is_empty() {
|
||||||
|
return view! {
|
||||||
|
<div class="flex flex-col items-center justify-center h-48 opacity-60">
|
||||||
|
<icons::File class="size-12 mb-3 text-muted-foreground" />
|
||||||
|
<p class="text-sm font-medium">"Bu torrent için dosya bulunamadı."</p>
|
||||||
|
</div>
|
||||||
|
}.into_any();
|
||||||
|
}
|
||||||
|
|
||||||
|
let files_len = files.len();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="space-y-4">
|
||||||
|
<TableWrapper class="bg-card/50">
|
||||||
|
<Table>
|
||||||
|
<TableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
||||||
|
<TableRow class="hover:bg-transparent">
|
||||||
|
<TableHead class="w-12 text-center text-xs">"#"</TableHead>
|
||||||
|
<TableHead class="text-xs">"Dosya Adı"</TableHead>
|
||||||
|
<TableHead class="w-24 text-right text-xs">"Boyut"</TableHead>
|
||||||
|
<TableHead class="w-24 text-right text-xs">"Tamamlanan"</TableHead>
|
||||||
|
<TableHead class="w-24 text-center text-xs">"Öncelik"</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<For
|
||||||
|
each=move || files.clone()
|
||||||
|
key=|f| f.index
|
||||||
|
children={move |f| {
|
||||||
|
let p_hash = stored_hash.get_value();
|
||||||
|
view! {
|
||||||
|
<FileRow
|
||||||
|
file=f
|
||||||
|
hash=p_hash
|
||||||
|
on_refresh=on_refresh.clone()
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableWrapper>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-xs text-muted-foreground px-1">
|
||||||
|
<span>{format!("Toplam {} dosya", files_len)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn FileRow(file: TorrentFile, hash: String, on_refresh: Callback<()>) -> impl IntoView {
|
||||||
|
let f_idx = file.index;
|
||||||
|
let path_clone = file.path.clone();
|
||||||
|
|
||||||
|
// on_refresh is called AFTER the server responds, not before
|
||||||
|
let on_refresh_stored = StoredValue::new(on_refresh);
|
||||||
|
|
||||||
|
let set_priority = Action::new(move |req: &(String, u32, u8)| {
|
||||||
|
let (h, idx, p) = req.clone();
|
||||||
|
async move {
|
||||||
|
let res = shared::server_fns::torrent::set_file_priority(h, idx, p).await;
|
||||||
|
if let Err(e) = &res {
|
||||||
|
crate::store::show_toast(shared::NotificationLevel::Error, format!("Öncelik değiştirilemedi: {:?}", e));
|
||||||
|
} else {
|
||||||
|
crate::store::show_toast(shared::NotificationLevel::Success, "Dosya önceliği güncellendi.".to_string());
|
||||||
|
// Refetch AFTER the server has saved the priority
|
||||||
|
on_refresh_stored.get_value().run(());
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<FileContextMenu
|
||||||
|
torrent_hash=hash
|
||||||
|
file_index=f_idx
|
||||||
|
set_priority=set_priority
|
||||||
|
>
|
||||||
|
<TableRow class="hover:bg-muted/50 transition-colors group">
|
||||||
|
<TableCell class="text-center text-xs text-muted-foreground">{file.index}</TableCell>
|
||||||
|
<TableCell class="font-medium text-xs break-all max-w-[200px] md:max-w-md" attr:title=move || path_clone.clone()>
|
||||||
|
{file.path.clone()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-right text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{format_bytes(file.size)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-right text-xs whitespace-nowrap">
|
||||||
|
<span class="text-primary font-medium">{format_bytes(file.completed_chunks)}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-center">
|
||||||
|
{
|
||||||
|
let (variant, label) = match file.priority {
|
||||||
|
0 => (BadgeVariant::Destructive, "İndirme"),
|
||||||
|
2 => (BadgeVariant::Success, "Yüksek"),
|
||||||
|
_ => (BadgeVariant::Secondary, "Normal"),
|
||||||
|
};
|
||||||
|
view! { <Badge variant=variant class="text-[10px] uppercase">{label}</Badge> }
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</FileContextMenu>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn FileContextMenu(
|
||||||
|
children: Children,
|
||||||
|
torrent_hash: String,
|
||||||
|
file_index: u32,
|
||||||
|
set_priority: Action<(String, u32, u8), Result<(), ServerFnError>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let hash_c1 = torrent_hash.clone();
|
||||||
|
let hash_c2 = torrent_hash.clone();
|
||||||
|
let hash_c3 = torrent_hash.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger>
|
||||||
|
{children()}
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
|
||||||
|
<ContextMenuContent class="w-48">
|
||||||
|
<ContextMenuLabel>"Dosya Önceliği"</ContextMenuLabel>
|
||||||
|
<ContextMenuGroup>
|
||||||
|
<ContextMenuItem on:click={
|
||||||
|
let h = hash_c1;
|
||||||
|
let sp = set_priority.clone();
|
||||||
|
move |_| {
|
||||||
|
sp.dispatch((h.clone(), file_index, 2));
|
||||||
|
crate::components::ui::context_menu::close_context_menu();
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<icons::ChevronsUp class="text-green-500" />
|
||||||
|
<span>"Yüksek"</span>
|
||||||
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
<ContextMenuItem on:click={
|
||||||
|
let h = hash_c2;
|
||||||
|
let sp = set_priority.clone();
|
||||||
|
move |_| {
|
||||||
|
sp.dispatch((h.clone(), file_index, 1));
|
||||||
|
crate::components::ui::context_menu::close_context_menu();
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<icons::Minus class="text-blue-500" />
|
||||||
|
<span>"Normal"</span>
|
||||||
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
<ContextMenuItem class="text-destructive focus:bg-destructive/10" on:click={
|
||||||
|
let h = hash_c3;
|
||||||
|
let sp = set_priority.clone();
|
||||||
|
move |_| {
|
||||||
|
sp.dispatch((h.clone(), file_index, 0));
|
||||||
|
crate::components::ui::context_menu::close_context_menu();
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<icons::X />
|
||||||
|
<span>"İndirme (Kapalı)"</span>
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuGroup>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn FilesFallback() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<Shimmer loading=Signal::derive(|| true) class="space-y-2">
|
||||||
|
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
|
||||||
|
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
|
||||||
|
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
|
||||||
|
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
|
||||||
|
</Shimmer>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_bytes(bytes: i64) -> String {
|
||||||
|
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
|
if bytes < 1024 { return format!("{} B", bytes); }
|
||||||
|
let i = (bytes as f64).log2().div_euclid(10.0) as usize;
|
||||||
|
format!("{:.1} {}", (bytes as f64) / 1024_f64.powi(i as i32), UNITS[i])
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod table;
|
pub mod table;
|
||||||
pub mod add_torrent;
|
pub mod add_torrent;
|
||||||
pub mod details;
|
pub mod details;
|
||||||
|
pub mod files;
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ pub fn ContextMenuTrigger(
|
|||||||
class=trigger_class
|
class=trigger_class
|
||||||
data-name="ContextMenuTrigger"
|
data-name="ContextMenuTrigger"
|
||||||
data-context-trigger=ctx.target_id
|
data-context-trigger=ctx.target_id
|
||||||
on:contextmenu=move |e: web_sys::MouseEvent| {
|
on:contextmenu=move |_e: web_sys::MouseEvent| {
|
||||||
if let Some(cb) = on_open {
|
if let Some(cb) = on_open {
|
||||||
cb.run(());
|
cb.run(());
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ mod components {
|
|||||||
clx! {SheetFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"}
|
clx! {SheetFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub use components::*;
|
|
||||||
|
|
||||||
/* ========================================================== */
|
/* ========================================================== */
|
||||||
/* ✨ CONTEXT ✨ */
|
/* ✨ CONTEXT ✨ */
|
||||||
/* ========================================================== */
|
/* ========================================================== */
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ use leptos::task::spawn_local;
|
|||||||
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
|
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use struct_patch::traits::Patch;
|
use struct_patch::traits::Patch;
|
||||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL};
|
use base64::{Engine as _, engine::general_purpose::{URL_SAFE_NO_PAD as BASE64_URL, STANDARD as BASE64}};
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
use crate::components::ui::toast::{ToastType, toast};
|
use crate::components::ui::toast::{ToastType, toast};
|
||||||
@@ -192,7 +191,7 @@ pub async fn subscribe_to_push_notifications() {
|
|||||||
let key_array = js_sys::Uint8Array::from(&decoded_key[..]);
|
let key_array = js_sys::Uint8Array::from(&decoded_key[..]);
|
||||||
|
|
||||||
// 3. Prepare Options
|
// 3. Prepare Options
|
||||||
let mut options = web_sys::PushSubscriptionOptionsInit::new();
|
let options = web_sys::PushSubscriptionOptionsInit::new();
|
||||||
options.set_user_visible_only(true);
|
options.set_user_visible_only(true);
|
||||||
options.set_application_server_key(&key_array.into());
|
options.set_application_server_key(&key_array.into());
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -225,6 +225,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 +233,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user