Compare commits

...

8 Commits

Author SHA1 Message Date
spinline
851d79029a feat(ui): add mobile long-press support for context menus
All checks were successful
Build MIPS Binary / build (push) Successful in 1m58s
2026-02-21 01:46:21 +03:00
spinline
ab27cf3eb4 fix: use f.priority.set instead of deprecated f.set_priority for rTorrent API
All checks were successful
Build MIPS Binary / build (push) Successful in 2m0s
2026-02-21 01:42:07 +03:00
spinline
7b4c9ff336 fix(files): trigger refetch only after set_priority completes on server
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 01:36:21 +03:00
spinline
743596d701 fix(ci): remove tracing calls that caused compile error in shared crate
All checks were successful
Build MIPS Binary / build (push) Successful in 1m58s
2026-02-21 01:31:25 +03:00
spinline
598f038ea6 fix(files): use refetch callback instead of Action to avoid reactive disposal panic
Some checks failed
Build MIPS Binary / build (push) Failing after 1m10s
2026-02-21 01:29:26 +03:00
spinline
7f8c721115 debug: add tracing logs to set_file_priority for diagnosis
Some checks failed
Build MIPS Binary / build (push) Failing after 1m10s
2026-02-21 01:27:22 +03:00
spinline
ba7f1ffd91 fix(ui): adjust context menu position for CSS transformed containers
All checks were successful
Build MIPS Binary / build (push) Successful in 1m59s
2026-02-21 01:22:31 +03:00
spinline
daa24dd7ec fix(ui): refactor FileContextMenu to auticlose and match homepage
All checks were successful
Build MIPS Binary / build (push) Successful in 1m59s
2026-02-21 01:13:57 +03:00
3 changed files with 222 additions and 144 deletions

View File

@@ -15,10 +15,9 @@ pub fn TorrentFilesTab(hash: String) -> impl IntoView {
|h| async move { shared::server_fns::torrent::get_files(h).await.unwrap_or_default() } |h| async move { shared::server_fns::torrent::get_files(h).await.unwrap_or_default() }
); );
// Refresh action // Callback to trigger a refetch — safe, doesn't destroy existing components
let refresh_files = Action::new(|h: &String| { let on_refresh = Callback::new(move |_: ()| {
let h = h.clone(); files_resource.refetch();
async move { shared::server_fns::torrent::get_files(h).await.unwrap_or_default() }
}); });
let stored_hash = StoredValue::new(hash); let stored_hash = StoredValue::new(hash);
@@ -26,10 +25,7 @@ pub fn TorrentFilesTab(hash: String) -> impl IntoView {
view! { view! {
<Suspense fallback=move || view! { <FilesFallback /> }> <Suspense fallback=move || view! { <FilesFallback /> }>
{move || { {move || {
let files = match refresh_files.value().get() { let files = files_resource.get().unwrap_or_default();
Some(f) => f,
None => files_resource.get().unwrap_or_default(),
};
if files.is_empty() { if files.is_empty() {
return view! { return view! {
@@ -61,12 +57,11 @@ pub fn TorrentFilesTab(hash: String) -> impl IntoView {
key=|f| f.index key=|f| f.index
children={move |f| { children={move |f| {
let p_hash = stored_hash.get_value(); let p_hash = stored_hash.get_value();
let r_files = refresh_files.clone();
view! { view! {
<FileRow <FileRow
file=f file=f
hash=p_hash hash=p_hash
refresh_action=r_files on_refresh=on_refresh.clone()
/> />
} }
}} }}
@@ -86,12 +81,14 @@ pub fn TorrentFilesTab(hash: String) -> impl IntoView {
} }
#[component] #[component]
fn FileRow(file: TorrentFile, hash: String, refresh_action: Action<String, Vec<TorrentFile>>) -> impl IntoView { fn FileRow(file: TorrentFile, hash: String, on_refresh: Callback<()>) -> impl IntoView {
let f_idx = file.index; let f_idx = file.index;
let context_id = format!("file-context-{}-{}", hash, f_idx);
let path_clone = file.path.clone(); let path_clone = file.path.clone();
let set_priority = Action::new(|req: &(String, u32, u8)| { // on_refresh is called AFTER the server responds, not before
let on_refresh_stored = StoredValue::new(on_refresh);
let set_priority = Action::new(move |req: &(String, u32, u8)| {
let (h, idx, p) = req.clone(); let (h, idx, p) = req.clone();
async move { async move {
let res = shared::server_fns::torrent::set_file_priority(h, idx, p).await; let res = shared::server_fns::torrent::set_file_priority(h, idx, p).await;
@@ -99,47 +96,71 @@ fn FileRow(file: TorrentFile, hash: String, refresh_action: Action<String, Vec<T
crate::store::show_toast(shared::NotificationLevel::Error, format!("Öncelik değiştirilemedi: {:?}", e)); crate::store::show_toast(shared::NotificationLevel::Error, format!("Öncelik değiştirilemedi: {:?}", e));
} else { } else {
crate::store::show_toast(shared::NotificationLevel::Success, "Dosya önceliği güncellendi.".to_string()); crate::store::show_toast(shared::NotificationLevel::Success, "Dosya önceliği güncellendi.".to_string());
// Refetch AFTER the server has saved the priority
on_refresh_stored.get_value().run(());
} }
res res
} }
}); });
view! {
<FileContextMenu
torrent_hash=hash
file_index=f_idx
set_priority=set_priority
>
<TableRow class="hover:bg-muted/50 transition-colors group">
<TableCell class="text-center text-xs text-muted-foreground">{file.index}</TableCell>
<TableCell class="font-medium text-xs break-all max-w-[200px] md:max-w-md" attr:title=move || path_clone.clone()>
{file.path.clone()}
</TableCell>
<TableCell class="text-right text-xs text-muted-foreground whitespace-nowrap">
{format_bytes(file.size)}
</TableCell>
<TableCell class="text-right text-xs whitespace-nowrap">
<span class="text-primary font-medium">{format_bytes(file.completed_chunks)}</span>
</TableCell>
<TableCell class="text-center">
{
let (variant, label) = match file.priority {
0 => (BadgeVariant::Destructive, "İndirme"),
2 => (BadgeVariant::Success, "Yüksek"),
_ => (BadgeVariant::Secondary, "Normal"),
};
view! { <Badge variant=variant class="text-[10px] uppercase">{label}</Badge> }
}
</TableCell>
</TableRow>
</FileContextMenu>
}
}
#[component]
fn FileContextMenu(
children: Children,
torrent_hash: String,
file_index: u32,
set_priority: Action<(String, u32, u8), Result<(), ServerFnError>>,
) -> impl IntoView {
let hash_c1 = torrent_hash.clone();
let hash_c2 = torrent_hash.clone();
let hash_c3 = torrent_hash.clone();
view! { view! {
<ContextMenu> <ContextMenu>
<ContextMenuTrigger attr:id=context_id.clone()> <ContextMenuTrigger>
<TableRow class="hover:bg-muted/50 transition-colors group"> {children()}
<TableCell class="text-center text-xs text-muted-foreground">{file.index}</TableCell>
<TableCell class="font-medium text-xs break-all max-w-[200px] md:max-w-md" attr:title=move || path_clone.clone()>
{file.path.clone()}
</TableCell>
<TableCell class="text-right text-xs text-muted-foreground whitespace-nowrap">
{format_bytes(file.size)}
</TableCell>
<TableCell class="text-right text-xs whitespace-nowrap">
<span class="text-primary font-medium">{format_bytes(file.completed_chunks)}</span>
</TableCell>
<TableCell class="text-center">
{
let (variant, label) = match file.priority {
0 => (BadgeVariant::Destructive, "İndirme"),
2 => (BadgeVariant::Success, "Yüksek"),
_ => (BadgeVariant::Secondary, "Normal"),
};
view! { <Badge variant=variant class="text-[10px] uppercase">{label}</Badge> }
}
</TableCell>
</TableRow>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent class="w-48"> <ContextMenuContent class="w-48">
<ContextMenuLabel>"Dosya Önceliği"</ContextMenuLabel> <ContextMenuLabel>"Dosya Önceliği"</ContextMenuLabel>
<ContextMenuGroup> <ContextMenuGroup>
<ContextMenuItem on:click={ <ContextMenuItem on:click={
let h = hash.clone(); let h = hash_c1;
let ra = refresh_action.clone(); let sp = set_priority.clone();
move |_| { move |_| {
set_priority.dispatch((h.clone(), f_idx, 2)); sp.dispatch((h.clone(), file_index, 2));
ra.dispatch(h.clone()); crate::components::ui::context_menu::close_context_menu();
} }
}> }>
<icons::ChevronsUp class="text-green-500" /> <icons::ChevronsUp class="text-green-500" />
@@ -147,11 +168,11 @@ fn FileRow(file: TorrentFile, hash: String, refresh_action: Action<String, Vec<T
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem on:click={ <ContextMenuItem on:click={
let h = hash.clone(); let h = hash_c2;
let ra = refresh_action.clone(); let sp = set_priority.clone();
move |_| { move |_| {
set_priority.dispatch((h.clone(), f_idx, 1)); sp.dispatch((h.clone(), file_index, 1));
ra.dispatch(h.clone()); crate::components::ui::context_menu::close_context_menu();
} }
}> }>
<icons::Minus class="text-blue-500" /> <icons::Minus class="text-blue-500" />
@@ -159,11 +180,11 @@ fn FileRow(file: TorrentFile, hash: String, refresh_action: Action<String, Vec<T
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem class="text-destructive focus:bg-destructive/10" on:click={ <ContextMenuItem class="text-destructive focus:bg-destructive/10" on:click={
let h = hash.clone(); let h = hash_c3;
let ra = refresh_action.clone(); let sp = set_priority.clone();
move |_| { move |_| {
set_priority.dispatch((h.clone(), f_idx, 0)); sp.dispatch((h.clone(), file_index, 0));
ra.dispatch(h.clone()); crate::components::ui::context_menu::close_context_menu();
} }
}> }>
<icons::X /> <icons::X />

View File

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

View File

@@ -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", &params) .call("f.priority.set", &params)
.await .await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?; .map_err(|e| ServerFnError::new(format!("RPC error setting priority: {}", e)))?;
// Notify rTorrent to update its internal priority state
let _ = client let _ = client
.call("d.update_priorities", &[RpcParam::from(hash.as_str())]) .call("d.update_priorities", &[RpcParam::from(hash.as_str())])
.await; .await;