Compare commits
3 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbb8e8dc98 | ||
|
|
d09ecd21b7 | ||
|
|
9a00e341af |
253
frontend/public/lock_scroll.js
Normal file
253
frontend/public/lock_scroll.js
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* Scroll Lock Utility
|
||||||
|
* Handles locking and unlocking scroll for both window and all scrollable containers
|
||||||
|
* Similar to react-remove-scroll but in vanilla JavaScript
|
||||||
|
*/
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
// Prevent multiple initializations
|
||||||
|
if (window.ScrollLock) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScrollLock {
|
||||||
|
constructor() {
|
||||||
|
this.locked = false;
|
||||||
|
this.scrollableElements = [];
|
||||||
|
this.scrollPositions = new Map();
|
||||||
|
this.originalStyles = new Map();
|
||||||
|
this.fixedElements = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all scrollable elements in the DOM (optimized)
|
||||||
|
* Uses more targeted selectors instead of querying all elements
|
||||||
|
*/
|
||||||
|
findScrollableElements() {
|
||||||
|
const scrollables = [];
|
||||||
|
|
||||||
|
// More targeted query - only look for elements with overflow properties
|
||||||
|
const candidates = document.querySelectorAll(
|
||||||
|
'[style*="overflow"], [class*="overflow"], [class*="scroll"], main, aside, section, div',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Batch all style reads first to minimize reflows
|
||||||
|
const elementsToCheck = [];
|
||||||
|
for (const el of candidates) {
|
||||||
|
// Skip the element itself or if it's inside these containers
|
||||||
|
const dataName = el.getAttribute("data-name");
|
||||||
|
const isExcludedElement =
|
||||||
|
dataName === "ScrollArea" ||
|
||||||
|
dataName === "CommandList" ||
|
||||||
|
dataName === "SelectContent" ||
|
||||||
|
dataName === "MultiSelectContent" ||
|
||||||
|
dataName === "DropdownMenuContent" ||
|
||||||
|
dataName === "ContextMenuContent";
|
||||||
|
|
||||||
|
if (
|
||||||
|
el !== document.body &&
|
||||||
|
el !== document.documentElement &&
|
||||||
|
!isExcludedElement &&
|
||||||
|
!el.closest('[data-name="ScrollArea"]') &&
|
||||||
|
!el.closest('[data-name="CommandList"]') &&
|
||||||
|
!el.closest('[data-name="SelectContent"]') &&
|
||||||
|
!el.closest('[data-name="MultiSelectContent"]') &&
|
||||||
|
!el.closest('[data-name="DropdownMenuContent"]') &&
|
||||||
|
!el.closest('[data-name="ContextMenuContent"]')
|
||||||
|
) {
|
||||||
|
elementsToCheck.push(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now batch read all computed styles and dimensions
|
||||||
|
elementsToCheck.forEach((el) => {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
const hasOverflow =
|
||||||
|
style.overflow === "auto" ||
|
||||||
|
style.overflow === "scroll" ||
|
||||||
|
style.overflowY === "auto" ||
|
||||||
|
style.overflowY === "scroll";
|
||||||
|
|
||||||
|
// Only check scrollHeight if overflow is set
|
||||||
|
if (hasOverflow && el.scrollHeight > el.clientHeight) {
|
||||||
|
scrollables.push(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return scrollables;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock scrolling on all scrollable elements (optimized)
|
||||||
|
* Batches all DOM reads before DOM writes to prevent forced reflows
|
||||||
|
*/
|
||||||
|
lock() {
|
||||||
|
if (this.locked) return;
|
||||||
|
|
||||||
|
this.locked = true;
|
||||||
|
|
||||||
|
// Find all scrollable elements
|
||||||
|
this.scrollableElements = this.findScrollableElements();
|
||||||
|
|
||||||
|
// ===== BATCH 1: READ PHASE - Read all layout properties first =====
|
||||||
|
const windowScrollY = window.scrollY;
|
||||||
|
const scrollbarWidth = window.innerWidth - document.body.clientWidth;
|
||||||
|
|
||||||
|
// Store window scroll position
|
||||||
|
this.scrollPositions.set("window", windowScrollY);
|
||||||
|
|
||||||
|
// Store original body styles
|
||||||
|
this.originalStyles.set("body", {
|
||||||
|
position: document.body.style.position,
|
||||||
|
top: document.body.style.top,
|
||||||
|
width: document.body.style.width,
|
||||||
|
overflow: document.body.style.overflow,
|
||||||
|
paddingRight: document.body.style.paddingRight,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read all fixed-position elements and their padding (only if we have scrollbar)
|
||||||
|
if (scrollbarWidth > 0) {
|
||||||
|
// Use more targeted query for fixed elements
|
||||||
|
const fixedCandidates = document.querySelectorAll(
|
||||||
|
'[style*="fixed"], [class*="fixed"], header, nav, aside, [role="dialog"], [role="alertdialog"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.fixedElements = Array.from(fixedCandidates).filter((el) => {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
return (
|
||||||
|
style.position === "fixed" &&
|
||||||
|
!el.closest('[data-name="DropdownMenuContent"]') &&
|
||||||
|
!el.closest('[data-name="MultiSelectContent"]') &&
|
||||||
|
!el.closest('[data-name="ContextMenuContent"]')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch read all padding values
|
||||||
|
this.fixedElements.forEach((el) => {
|
||||||
|
const computedStyle = window.getComputedStyle(el);
|
||||||
|
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
|
||||||
|
|
||||||
|
this.originalStyles.set(el, {
|
||||||
|
paddingRight: el.style.paddingRight,
|
||||||
|
computedPadding: currentPadding,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read scrollable elements info
|
||||||
|
const scrollableInfo = this.scrollableElements.map((el) => {
|
||||||
|
const scrollTop = el.scrollTop;
|
||||||
|
const elementScrollbarWidth = el.offsetWidth - el.clientWidth;
|
||||||
|
const computedStyle = window.getComputedStyle(el);
|
||||||
|
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
|
||||||
|
|
||||||
|
this.scrollPositions.set(el, scrollTop);
|
||||||
|
this.originalStyles.set(el, {
|
||||||
|
overflow: el.style.overflow,
|
||||||
|
overflowY: el.style.overflowY,
|
||||||
|
paddingRight: el.style.paddingRight,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { el, elementScrollbarWidth, currentPadding };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== BATCH 2: WRITE PHASE - Apply all styles at once =====
|
||||||
|
|
||||||
|
// Apply body lock
|
||||||
|
document.body.style.position = "fixed";
|
||||||
|
document.body.style.top = `-${windowScrollY}px`;
|
||||||
|
document.body.style.width = "100%";
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
if (scrollbarWidth > 0) {
|
||||||
|
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||||
|
|
||||||
|
// Apply padding compensation to fixed elements
|
||||||
|
this.fixedElements.forEach((el) => {
|
||||||
|
const stored = this.originalStyles.get(el);
|
||||||
|
if (stored) {
|
||||||
|
el.style.paddingRight = `${stored.computedPadding + scrollbarWidth}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock all scrollable containers
|
||||||
|
scrollableInfo.forEach(({ el, elementScrollbarWidth, currentPadding }) => {
|
||||||
|
el.style.overflow = "hidden";
|
||||||
|
|
||||||
|
if (elementScrollbarWidth > 0) {
|
||||||
|
el.style.paddingRight = `${currentPadding + elementScrollbarWidth}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlock scrolling on all elements (optimized)
|
||||||
|
* @param {number} delay - Delay in milliseconds before unlocking (for animations)
|
||||||
|
*/
|
||||||
|
unlock(delay = 0) {
|
||||||
|
if (!this.locked) return;
|
||||||
|
|
||||||
|
const performUnlock = () => {
|
||||||
|
// Restore body scroll
|
||||||
|
const bodyStyles = this.originalStyles.get("body");
|
||||||
|
if (bodyStyles) {
|
||||||
|
document.body.style.position = bodyStyles.position;
|
||||||
|
document.body.style.top = bodyStyles.top;
|
||||||
|
document.body.style.width = bodyStyles.width;
|
||||||
|
document.body.style.overflow = bodyStyles.overflow;
|
||||||
|
document.body.style.paddingRight = bodyStyles.paddingRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore window scroll position
|
||||||
|
const windowScrollY = this.scrollPositions.get("window") || 0;
|
||||||
|
window.scrollTo(0, windowScrollY);
|
||||||
|
|
||||||
|
// Restore all scrollable containers
|
||||||
|
this.scrollableElements.forEach((el) => {
|
||||||
|
const originalStyles = this.originalStyles.get(el);
|
||||||
|
if (originalStyles) {
|
||||||
|
el.style.overflow = originalStyles.overflow;
|
||||||
|
el.style.overflowY = originalStyles.overflowY;
|
||||||
|
el.style.paddingRight = originalStyles.paddingRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore scroll position
|
||||||
|
const scrollPosition = this.scrollPositions.get(el) || 0;
|
||||||
|
el.scrollTop = scrollPosition;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore fixed-position elements padding
|
||||||
|
this.fixedElements.forEach((el) => {
|
||||||
|
const styles = this.originalStyles.get(el);
|
||||||
|
if (styles && styles.paddingRight !== undefined) {
|
||||||
|
el.style.paddingRight = styles.paddingRight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear storage
|
||||||
|
this.scrollableElements = [];
|
||||||
|
this.fixedElements = [];
|
||||||
|
this.scrollPositions.clear();
|
||||||
|
this.originalStyles.clear();
|
||||||
|
this.locked = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (delay > 0) {
|
||||||
|
setTimeout(performUnlock, delay);
|
||||||
|
} else {
|
||||||
|
performUnlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if scrolling is currently locked
|
||||||
|
*/
|
||||||
|
isLocked() {
|
||||||
|
return this.locked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export as singleton
|
||||||
|
window.ScrollLock = new ScrollLock();
|
||||||
|
})();
|
||||||
@@ -19,22 +19,31 @@ pub fn TorrentContextMenu(
|
|||||||
{children()}
|
{children()}
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
|
||||||
<ContextMenuContent>
|
<ContextMenuContent class="w-56">
|
||||||
<ContextMenuAction on:click=move |_| menu_action("start")>
|
<ContextMenuAction
|
||||||
|
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
|
||||||
|
on:click=move |_| menu_action("start")
|
||||||
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||||
</svg>
|
</svg>
|
||||||
"Start"
|
"Start"
|
||||||
</ContextMenuAction>
|
</ContextMenuAction>
|
||||||
|
|
||||||
<ContextMenuAction on:click=move |_| menu_action("stop")>
|
<ContextMenuAction
|
||||||
|
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
|
||||||
|
on:click=move |_| menu_action("stop")
|
||||||
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
"Stop"
|
"Stop"
|
||||||
</ContextMenuAction>
|
</ContextMenuAction>
|
||||||
|
|
||||||
<ContextMenuAction on:click=move |_| menu_action("recheck")>
|
<ContextMenuAction
|
||||||
|
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
|
||||||
|
on:click=move |_| menu_action("recheck")
|
||||||
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -44,7 +53,7 @@ pub fn TorrentContextMenu(
|
|||||||
<div class="-mx-1 my-1 h-px bg-border" />
|
<div class="-mx-1 my-1 h-px bg-border" />
|
||||||
|
|
||||||
<ContextMenuAction
|
<ContextMenuAction
|
||||||
class="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
class="px-2 py-1.5 text-destructive hover:bg-destructive/10 hover:text-destructive rounded-sm"
|
||||||
on:click=move |_| menu_action("delete")
|
on:click=move |_| menu_action("delete")
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
@@ -53,15 +62,16 @@ pub fn TorrentContextMenu(
|
|||||||
"Remove"
|
"Remove"
|
||||||
</ContextMenuAction>
|
</ContextMenuAction>
|
||||||
|
|
||||||
<ContextMenuAction
|
<ContextMenuHoldAction
|
||||||
class="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
on:click=move |_| menu_action("delete_with_data")
|
on_hold_complete=move |_| menu_action("delete_with_data")
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||||
</svg>
|
</svg>
|
||||||
"Remove with Data"
|
"Remove with Data"
|
||||||
</ContextMenuAction>
|
<span class="ml-auto text-[10px] opacity-50">"Hold"</span>
|
||||||
|
</ContextMenuHoldAction>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ use crate::store::{get_action_messages, show_toast};
|
|||||||
use crate::api;
|
use crate::api;
|
||||||
use shared::NotificationLevel;
|
use shared::NotificationLevel;
|
||||||
use crate::components::context_menu::TorrentContextMenu;
|
use crate::components::context_menu::TorrentContextMenu;
|
||||||
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent};
|
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent as CardBody};
|
||||||
|
use crate::components::ui::table::*;
|
||||||
|
|
||||||
fn format_bytes(bytes: i64) -> String {
|
fn format_bytes(bytes: i64) -> String {
|
||||||
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
@@ -106,10 +107,10 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
let sort_arrow = move |col: SortColumn| {
|
let sort_arrow = move |col: SortColumn| {
|
||||||
if sort_col.0.get() == col {
|
if sort_col.0.get() == col {
|
||||||
match sort_dir.0.get() {
|
match sort_dir.0.get() {
|
||||||
SortDirection::Ascending => view! { <span class="ml-1 text-xs">"▲"</span> }.into_any(),
|
SortDirection::Ascending => view! { <span class="ml-1 text-[10px]">"▲"</span> }.into_any(),
|
||||||
SortDirection::Descending => view! { <span class="ml-1 text-xs">"▼"</span> }.into_any(),
|
SortDirection::Descending => view! { <span class="ml-1 text-[10px]">"▼"</span> }.into_any(),
|
||||||
}
|
}
|
||||||
} else { view! { <span class="ml-1 text-xs opacity-0 group-hover:opacity-50">"▲"</span> }.into_any() }
|
} else { view! { <span class="ml-1 text-[10px] opacity-0 group-hover:opacity-50 transition-opacity">"▲"</span> }.into_any() }
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_action = Callback::new(move |(action, hash): (String, String)| {
|
let on_action = Callback::new(move |(action, hash): (String, String)| {
|
||||||
@@ -132,51 +133,56 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="h-full bg-background relative flex flex-col overflow-hidden">
|
<div class="h-full bg-background relative flex flex-col overflow-hidden px-4 py-2">
|
||||||
// --- DESKTOP VIEW ---
|
// --- DESKTOP VIEW ---
|
||||||
<div class="hidden md:flex flex-col h-full overflow-hidden">
|
<div class="hidden md:flex flex-col h-full overflow-hidden">
|
||||||
// Header
|
<TableWrapper class="flex-1 flex flex-col min-h-0 bg-card/50">
|
||||||
<div class="flex items-center text-xs uppercase text-muted-foreground border-b border-border bg-muted/50 h-9 shrink-0 px-2 font-medium">
|
<div class="flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
<div class="flex-1 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Name)>
|
<Table class="w-full">
|
||||||
"Name" {move || sort_arrow(SortColumn::Name)}
|
<TableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
||||||
|
<TableRow class="hover:bg-transparent">
|
||||||
|
<TableHead class="cursor-pointer group select-none whitespace-nowrap" on:click=move |_| handle_sort(SortColumn::Name)>
|
||||||
|
<div class="flex items-center">"Name" {move || sort_arrow(SortColumn::Name)}</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="w-24 cursor-pointer group select-none whitespace-nowrap" on:click=move |_| handle_sort(SortColumn::Size)>
|
||||||
|
<div class="flex items-center">"Size" {move || sort_arrow(SortColumn::Size)}</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="w-48 cursor-pointer group select-none whitespace-nowrap" on:click=move |_| handle_sort(SortColumn::Progress)>
|
||||||
|
<div class="flex items-center">"Progress" {move || sort_arrow(SortColumn::Progress)}</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="w-24 cursor-pointer group select-none whitespace-nowrap" on:click=move |_| handle_sort(SortColumn::Status)>
|
||||||
|
<div class="flex items-center">"Status" {move || sort_arrow(SortColumn::Status)}</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="w-24 cursor-pointer group select-none whitespace-nowrap" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
||||||
|
<div class="flex items-center">"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="w-24 cursor-pointer group select-none whitespace-nowrap" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
||||||
|
<div class="flex items-center">"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="w-24 cursor-pointer group select-none whitespace-nowrap" on:click=move |_| handle_sort(SortColumn::ETA)>
|
||||||
|
<div class="flex items-center">"ETA" {move || sort_arrow(SortColumn::ETA)}</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="w-32 cursor-pointer group select-none whitespace-nowrap" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
||||||
|
<div class="flex items-center">"Date" {move || sort_arrow(SortColumn::AddedDate)}</div>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
||||||
|
let on_action = on_action.clone();
|
||||||
|
move |hash| {
|
||||||
|
let h = hash.clone();
|
||||||
|
view! {
|
||||||
|
<TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
|
||||||
|
<TorrentRow hash=hash.clone() />
|
||||||
|
</TorrentContextMenu>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} />
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Size)>
|
</TableWrapper>
|
||||||
"Size" {move || sort_arrow(SortColumn::Size)}
|
|
||||||
</div>
|
|
||||||
<div class="w-48 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Progress)>
|
|
||||||
"Progress" {move || sort_arrow(SortColumn::Progress)}
|
|
||||||
</div>
|
|
||||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Status)>
|
|
||||||
"Status" {move || sort_arrow(SortColumn::Status)}
|
|
||||||
</div>
|
|
||||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
|
||||||
"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}
|
|
||||||
</div>
|
|
||||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
|
||||||
"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}
|
|
||||||
</div>
|
|
||||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::ETA)>
|
|
||||||
"ETA" {move || sort_arrow(SortColumn::ETA)}
|
|
||||||
</div>
|
|
||||||
<div class="w-32 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
|
||||||
"Date" {move || sort_arrow(SortColumn::AddedDate)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Regular List
|
|
||||||
<div class="flex-1 overflow-y-auto min-h-0">
|
|
||||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
|
||||||
let on_action = on_action.clone();
|
|
||||||
move |hash| {
|
|
||||||
let h = hash.clone();
|
|
||||||
view! {
|
|
||||||
<TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
|
|
||||||
<TorrentRow hash=hash.clone() />
|
|
||||||
</TorrentContextMenu>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// --- MOBILE VIEW ---
|
// --- MOBILE VIEW ---
|
||||||
@@ -198,7 +204,7 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}.into_any()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
@@ -219,36 +225,51 @@ fn TorrentRow(
|
|||||||
let t_name = t.name.clone();
|
let t_name = t.name.clone();
|
||||||
let status_color = match t.status { shared::TorrentStatus::Seeding => "text-green-500", shared::TorrentStatus::Downloading => "text-blue-500", shared::TorrentStatus::Paused => "text-yellow-500", shared::TorrentStatus::Error => "text-red-500", _ => "text-muted-foreground" };
|
let status_color = match t.status { shared::TorrentStatus::Seeding => "text-green-500", shared::TorrentStatus::Downloading => "text-blue-500", shared::TorrentStatus::Paused => "text-yellow-500", shared::TorrentStatus::Error => "text-red-500", _ => "text-muted-foreground" };
|
||||||
|
|
||||||
|
let is_selected = Memo::new(move |_| {
|
||||||
|
let selected = store.selected_torrent.get();
|
||||||
|
selected.as_deref() == Some(stored_hash.get_value().as_str())
|
||||||
|
});
|
||||||
|
|
||||||
|
let t_name_for_title = t_name.clone();
|
||||||
|
let t_name_for_content = t_name.clone();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<TableRow
|
||||||
class=move || {
|
class="cursor-pointer h-12"
|
||||||
let selected = store.selected_torrent.get();
|
attr:data-state=move || if is_selected.get() { "selected" } else { "" }
|
||||||
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
|
|
||||||
if is_selected {
|
|
||||||
"flex items-center text-sm bg-primary/10 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full"
|
|
||||||
} else {
|
|
||||||
"flex items-center text-sm hover:bg-muted/50 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||||
>
|
>
|
||||||
<div class="flex-1 min-w-0 px-2 font-medium truncate" title=t_name.clone()>{t_name.clone()}</div>
|
<TableCell class="font-medium truncate max-w-[200px] lg:max-w-md px-2 py-0 h-12 flex items-center border-0" attr:title=t_name_for_title>
|
||||||
<div class="w-24 px-2 font-mono text-xs text-muted-foreground">{format_bytes(t.size)}</div>
|
{t_name_for_content}
|
||||||
<div class="w-48 px-2">
|
</TableCell>
|
||||||
|
<TableCell class="font-mono text-xs text-muted-foreground px-2">
|
||||||
|
{format_bytes(t.size)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="px-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
|
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>
|
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TableCell>
|
||||||
<div class={format!("w-24 px-2 text-xs font-medium {}", status_color)}>{format!("{:?}", t.status)}</div>
|
<TableCell class={format!("px-2 text-xs font-semibold {}", status_color)}>
|
||||||
<div class="w-24 px-2 text-right font-mono text-xs text-green-600 dark:text-green-500">{format_speed(t.down_rate)}</div>
|
{format!("{:?}", t.status)}
|
||||||
<div class="w-24 px-2 text-right font-mono text-xs text-blue-600 dark:text-blue-500">{format_speed(t.up_rate)}</div>
|
</TableCell>
|
||||||
<div class="w-24 px-2 text-right font-mono text-xs text-muted-foreground">{format_duration(t.eta)}</div>
|
<TableCell class="text-right font-mono text-xs text-green-600 dark:text-green-500 px-2 whitespace-nowrap">
|
||||||
<div class="w-32 px-2 text-right font-mono text-xs text-muted-foreground">{format_date(t.added_date)}</div>
|
{format_speed(t.down_rate)}
|
||||||
</div>
|
</TableCell>
|
||||||
}
|
<TableCell class="text-right font-mono text-xs text-blue-600 dark:text-blue-500 px-2 whitespace-nowrap">
|
||||||
|
{format_speed(t.up_rate)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-right font-mono text-xs text-muted-foreground px-2 whitespace-nowrap">
|
||||||
|
{format_duration(t.eta)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-right font-mono text-xs text-muted-foreground px-2 whitespace-nowrap">
|
||||||
|
{format_date(t.added_date)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
}.into_any()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -293,7 +314,7 @@ fn TorrentCard(
|
|||||||
<div class={format!("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 {}", status_badge_class)}>{format!("{:?}", t.status)}</div>
|
<div class={format!("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 {}", status_badge_class)}>{format!("{:?}", t.status)}</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="p-3 pt-2 gap-3 flex flex-col">
|
<CardBody class="p-3 pt-2 gap-3 flex flex-col">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div class="flex justify-between text-[10px] text-muted-foreground">
|
<div class="flex justify-between text-[10px] text-muted-foreground">
|
||||||
<span>{format_bytes(t.size)}</span>
|
<span>{format_bytes(t.size)}</span>
|
||||||
@@ -309,7 +330,7 @@ fn TorrentCard(
|
|||||||
<div class="flex flex-col"><span>"ETA"</span><span>{format_duration(t.eta)}</span></div>
|
<div class="flex flex-col"><span>"ETA"</span><span>{format_duration(t.eta)}</span></div>
|
||||||
<div class="flex flex-col text-right"><span>"DATE"</span><span>{format_date(t.added_date)}</span></div>
|
<div class="flex flex-col text-right"><span>"DATE"</span><span>{format_date(t.added_date)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -317,4 +338,4 @@ fn TorrentCard(
|
|||||||
}
|
}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,6 +78,76 @@ pub fn ContextMenuAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ContextMenuHoldAction(
|
||||||
|
children: Children,
|
||||||
|
#[prop(into)] on_hold_complete: Callback<()>,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(default = 1000)] hold_duration: u64,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let is_holding = RwSignal::new(false);
|
||||||
|
let progress = RwSignal::new(0.0);
|
||||||
|
|
||||||
|
let on_mousedown = move |_| {
|
||||||
|
is_holding.set(true);
|
||||||
|
progress.set(0.0);
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_mouseup = move |_| {
|
||||||
|
is_holding.set(false);
|
||||||
|
progress.set(0.0);
|
||||||
|
};
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if is_holding.get() {
|
||||||
|
let start_time = js_sys::Date::now();
|
||||||
|
let duration = hold_duration as f64;
|
||||||
|
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
while is_holding.get_untracked() {
|
||||||
|
let now = js_sys::Date::now();
|
||||||
|
let elapsed = now - start_time;
|
||||||
|
let p = (elapsed / duration).min(1.0);
|
||||||
|
progress.set(p * 100.0);
|
||||||
|
|
||||||
|
if p >= 1.0 {
|
||||||
|
on_hold_complete.run(());
|
||||||
|
is_holding.set(false);
|
||||||
|
close_context_menu();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
gloo_timers::future::TimeoutFuture::new(16).await; // ~60fps
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let class = tw_merge!(
|
||||||
|
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors overflow-hidden",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class=class
|
||||||
|
on:mousedown=on_mousedown
|
||||||
|
on:mouseup=on_mouseup
|
||||||
|
on:mouseleave=on_mouseup
|
||||||
|
on:touchstart=move |_| on_mousedown(web_sys::MouseEvent::new("mousedown").unwrap())
|
||||||
|
on:touchend=move |_| on_mouseup(web_sys::MouseEvent::new("mouseup").unwrap())
|
||||||
|
>
|
||||||
|
// Progress background
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 left-0 bg-destructive/20 transition-all duration-75 ease-linear pointer-events-none"
|
||||||
|
style=move || format!("width: {}%;", progress.get())
|
||||||
|
/>
|
||||||
|
<span class="relative z-10 flex items-center gap-2 w-full">
|
||||||
|
{children()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ContextMenuContext {
|
struct ContextMenuContext {
|
||||||
target_id: String,
|
target_id: String,
|
||||||
@@ -160,14 +230,14 @@ pub fn ContextMenuContent(
|
|||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let ctx = expect_context::<ContextMenuContext>();
|
let ctx = expect_context::<ContextMenuContext>();
|
||||||
|
|
||||||
let base_classes = "z-50 p-1 rounded-md border bg-card shadow-md w-[200px] fixed transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100";
|
let base_classes = "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100";
|
||||||
|
|
||||||
let class = tw_merge!(base_classes, class);
|
let class = tw_merge!(base_classes, class);
|
||||||
|
|
||||||
let target_id_for_script = ctx.target_id.clone();
|
let target_id_for_script = ctx.target_id.clone();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<script src="/hooks/lock_scroll.js"></script>
|
<script src="/lock_scroll.js"></script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-name="ContextMenuContent"
|
data-name="ContextMenuContent"
|
||||||
@@ -329,7 +399,7 @@ pub fn ContextMenuContent(
|
|||||||
target_id_for_script,
|
target_id_for_script,
|
||||||
)}
|
)}
|
||||||
</script>
|
</script>
|
||||||
}
|
}.into_any()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ pub mod toast;
|
|||||||
pub mod context_menu;
|
pub mod context_menu;
|
||||||
pub mod theme_toggle;
|
pub mod theme_toggle;
|
||||||
pub mod svg_icon;
|
pub mod svg_icon;
|
||||||
|
pub mod table;
|
||||||
44
frontend/src/components/ui/table.rs
Normal file
44
frontend/src/components/ui/table.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use tw_merge::tw_merge;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TableWrapper(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("overflow-hidden rounded-md border", class);
|
||||||
|
view! { <div class=class>{children()}</div> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Table(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("w-full text-sm caption-bottom", class);
|
||||||
|
view! { <table class=class>{children()}</table> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TableHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("[&_tr]:border-b", class);
|
||||||
|
view! { <thead class=class>{children()}</thead> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TableRow(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("border-b transition-colors data-[state=selected]:bg-muted hover:bg-muted/50", class);
|
||||||
|
view! { <tr class=class>{children()}</tr> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TableHead(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("h-10 px-2 text-left align-middle font-medium text-muted-foreground", class);
|
||||||
|
view! { <th class=class>{children()}</th> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TableBody(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("[&_tr:last-child]:border-0", class);
|
||||||
|
view! { <tbody class=class>{children()}</tbody> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TableCell(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("p-2 align-middle", class);
|
||||||
|
view! { <td class=class>{children()}</td> }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user