diff --git a/Cargo.lock b/Cargo.lock index 7887a70..90a6c95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1259,6 +1259,7 @@ dependencies = [ "gloo-console", "gloo-net", "gloo-timers", + "icons", "js-sys", "leptos", "leptos-use", @@ -1271,7 +1272,7 @@ dependencies = [ "serde_json", "shared", "struct-patch", - "strum", + "strum 0.26.3", "tailwind_fuse", "thiserror 2.0.18", "tw_merge", @@ -1864,6 +1865,17 @@ dependencies = [ "cc", ] +[[package]] +name = "icons" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75da3d473e24e0b541bf28cf31e67b262c379a4cbc2149f4865b7d99406711dc" +dependencies = [ + "leptos", + "strum 0.27.2", + "tw_merge", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -4137,7 +4149,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", ] [[package]] @@ -4153,6 +4174,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/frontend/input.css b/frontend/input.css index 89d9c96..c25e051 100644 --- a/frontend/input.css +++ b/frontend/input.css @@ -1,177 +1,92 @@ @import "tailwindcss"; -@config "./tailwind.config.js"; -@source "../src/**/*.rs"; -@source "/Users/bilal/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/leptos-shadcn-*/src/**/*.rs"; +@import "tw-animate-css"; -@theme { - /* Shadcn Colors */ - --color-border: hsl(var(--border)); - --color-input: hsl(var(--input)); - --color-ring: hsl(var(--ring)); - --color-background: hsl(var(--background)); - --color-foreground: hsl(var(--foreground)); - --color-primary: hsl(var(--primary)); - --color-primary-foreground: hsl(var(--primary-foreground)); +:root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; + --radius: 0.5rem; +} - --color-secondary: hsl(var(--secondary)); - --color-secondary-foreground: hsl(var(--secondary-foreground)); +.dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; +} - --color-destructive: hsl(var(--destructive)); - --color-destructive-foreground: hsl(var(--destructive-foreground)); - - --color-muted: hsl(var(--muted)); - --color-muted-foreground: hsl(var(--muted-foreground)); - - --color-accent: hsl(var(--accent)); - --color-accent-foreground: hsl(var(--accent-foreground)); - - --color-popover: hsl(var(--popover)); - --color-popover-foreground: hsl(var(--popover-foreground)); - - --color-card: hsl(var(--card)); - --color-card-foreground: hsl(var(--card-foreground)); - - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 4px); - - --animate-accordion-down: accordion-down 0.2s ease-out; - --animate-accordion-up: accordion-up 0.2s ease-out; - - @keyframes accordion-down { - from { - height: 0; - } - - to { - height: var(--radix-accordion-content-height); - } - } - - @keyframes accordion-up { - from { - height: var(--radix-accordion-content-height); - } - - to { - height: 0; - } - } +@theme inline { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } @layer base { - :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; - - --radius: 0.5rem; - } - - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - } + dialog { + margin: auto; + } } - -@layer base { - * { - @apply border-border; - } - - body { - @apply bg-background text-foreground; - } - - /* Ensure Shadcn Utilities are always available */ - .bg-popover { - background-color: hsl(var(--popover)); - } - - .text-popover-foreground { - color: hsl(var(--popover-foreground)); - } - - .border-border { - border-color: hsl(var(--border)); - } - - .shadow-md { - box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - } - - .z-50 { - z-index: 50; - } - - .z-100 { - z-index: 100; - } -} - -/* Fix for iOS click/blur events */ -@media (hover: none) { - body { - cursor: pointer; - } -} - -/* Remove default focus outline/tap highlight */ -* { - -webkit-tap-highlight-color: transparent; - outline: none !important; -} - -:focus { - outline: none !important; -} \ No newline at end of file diff --git a/frontend/public/hooks/lock_scroll.js b/frontend/public/hooks/lock_scroll.js new file mode 100644 index 0000000..48c04eb --- /dev/null +++ b/frontend/public/hooks/lock_scroll.js @@ -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(); +})(); diff --git a/frontend/src/components/torrent/table.rs b/frontend/src/components/torrent/table.rs index 0d5f17c..10941ab 100644 --- a/frontend/src/components/torrent/table.rs +++ b/frontend/src/components/torrent/table.rs @@ -220,7 +220,7 @@ pub fn TorrentTable() -> impl IntoView {