Compare commits
4 Commits
57abbb3335
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbb8e8dc98 | ||
|
|
d09ecd21b7 | ||
|
|
9a00e341af | ||
|
|
c78dcda55e |
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();
|
||||||
|
})();
|
||||||
@@ -7,10 +7,27 @@ use leptos::task::spawn_local;
|
|||||||
use leptos_router::components::{Router, Routes, Route};
|
use leptos_router::components::{Router, Routes, Route};
|
||||||
use leptos_router::hooks::use_navigate;
|
use leptos_router::hooks::use_navigate;
|
||||||
use crate::components::ui::toast::Toaster;
|
use crate::components::ui::toast::Toaster;
|
||||||
|
use crate::components::hooks::use_theme_mode::ThemeMode;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
crate::components::ui::toast::provide_toaster();
|
crate::components::ui::toast::provide_toaster();
|
||||||
|
let theme_mode = ThemeMode::init();
|
||||||
|
|
||||||
|
// Sync theme with document
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let is_dark = theme_mode.get();
|
||||||
|
if let Some(doc) = document().document_element() {
|
||||||
|
if is_dark {
|
||||||
|
let _ = doc.class_list().add_1("dark");
|
||||||
|
let _ = doc.set_attribute("data-theme", "dark");
|
||||||
|
} else {
|
||||||
|
let _ = doc.class_list().remove_1("dark");
|
||||||
|
let _ = doc.set_attribute("data-theme", "light");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<InnerApp />
|
<InnerApp />
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
pub mod use_random;
|
pub mod use_random;
|
||||||
|
pub mod use_theme_mode;
|
||||||
|
|||||||
108
frontend/src/components/hooks/use_theme_mode.rs
Normal file
108
frontend/src/components/hooks/use_theme_mode.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use web_sys::Storage;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ThemeMode {
|
||||||
|
state: RwSignal<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCALSTORAGE_KEY: &str = "darkmode";
|
||||||
|
|
||||||
|
/// Hook to access the dark mode context
|
||||||
|
///
|
||||||
|
/// Returns the ThemeMode instance from context for easy access
|
||||||
|
pub fn use_theme_mode() -> ThemeMode {
|
||||||
|
expect_context::<ThemeMode>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* ✨ FUNCTIONS ✨ */
|
||||||
|
/* ========================================================== */
|
||||||
|
|
||||||
|
impl ThemeMode {
|
||||||
|
#[must_use]
|
||||||
|
/// Initializes a new ThemeMode instance.
|
||||||
|
pub fn init() -> Self {
|
||||||
|
let theme_mode = Self { state: RwSignal::new(false) };
|
||||||
|
|
||||||
|
provide_context(theme_mode);
|
||||||
|
|
||||||
|
// Use Effect to handle browser-only initialization
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let initial = Self::get_storage_state().unwrap_or(Self::prefers_dark_mode());
|
||||||
|
theme_mode.state.set(initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
theme_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle(&self) {
|
||||||
|
self.state.update(|state| {
|
||||||
|
*state = !*state;
|
||||||
|
Self::set_storage_state(*state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_dark(&self) {
|
||||||
|
self.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_light(&self) {
|
||||||
|
self.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - `dark`: Set to `true` for dark mode, and `false` for light mode.
|
||||||
|
pub fn set(&self, dark: bool) {
|
||||||
|
self.state.set(dark);
|
||||||
|
Self::set_storage_state(dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn get(&self) -> bool {
|
||||||
|
self.state.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_dark(&self) -> bool {
|
||||||
|
self.state.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_light(&self) -> bool {
|
||||||
|
!self.state.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* ✨ FUNCTIONS ✨ */
|
||||||
|
/* ========================================================== */
|
||||||
|
|
||||||
|
/// Retrieves the local storage object, if available.
|
||||||
|
fn get_storage() -> Option<Storage> {
|
||||||
|
window().local_storage().ok().flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves the dark mode state from local storage, if available.
|
||||||
|
fn get_storage_state() -> Option<bool> {
|
||||||
|
Self::get_storage()
|
||||||
|
.and_then(|storage| storage.get(LOCALSTORAGE_KEY).ok())
|
||||||
|
.flatten()
|
||||||
|
.and_then(|entry| entry.parse::<bool>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks whether the user's system prefers dark mode based on media queries.
|
||||||
|
fn prefers_dark_mode() -> bool {
|
||||||
|
window()
|
||||||
|
.match_media("(prefers-color-scheme: dark)")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|media| media.matches())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the dark mode state in local storage.
|
||||||
|
fn set_storage_state(state: bool) {
|
||||||
|
if let Some(storage) = Self::get_storage() {
|
||||||
|
storage.set(LOCALSTORAGE_KEY, state.to_string().as_str()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
|
|
||||||
use leptos_use::storage::use_local_storage;
|
|
||||||
use ::codee::string::FromToStringCodec;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Sidebar() -> impl IntoView {
|
pub fn Sidebar() -> impl IntoView {
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
@@ -67,34 +64,6 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
username().chars().next().unwrap_or('?').to_uppercase().to_string()
|
username().chars().next().unwrap_or('?').to_uppercase().to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- THEME LOGIC START ---
|
|
||||||
let (current_theme, set_current_theme, _) = use_local_storage::<String, FromToStringCodec>("vibetorrent_theme");
|
|
||||||
|
|
||||||
// Initialize with default if empty
|
|
||||||
let current_theme_val = current_theme.get();
|
|
||||||
if current_theme_val.is_empty() {
|
|
||||||
set_current_theme.set("dark".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Automatically sync theme to document attribute
|
|
||||||
Effect::new(move |_| {
|
|
||||||
let theme = current_theme.get().to_lowercase();
|
|
||||||
if let Some(doc) = document().document_element() {
|
|
||||||
let _ = doc.set_attribute("data-theme", &theme);
|
|
||||||
if theme == "dark" || theme == "dracula" || theme == "dim" || theme == "abyss" || theme == "sunset" || theme == "cyberpunk" || theme == "nord" || theme == "business" || theme == "night" || theme == "black" || theme == "luxury" || theme == "coffee" || theme == "forest" || theme == "halloween" || theme == "synthwave" {
|
|
||||||
let _ = doc.class_list().add_1("dark");
|
|
||||||
} else {
|
|
||||||
let _ = doc.class_list().remove_1("dark");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let toggle_theme = move |_| {
|
|
||||||
let new_theme = if current_theme.get() == "dark" { "light" } else { "dark" };
|
|
||||||
set_current_theme.set(new_theme.to_string());
|
|
||||||
};
|
|
||||||
// --- THEME LOGIC END ---
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="w-full h-full flex flex-col bg-card" style="padding-top: env(safe-area-inset-top);">
|
<div class="w-full h-full flex flex-col bg-card" style="padding-top: env(safe-area-inset-top);">
|
||||||
<div class="p-4 flex-1 overflow-y-auto">
|
<div class="p-4 flex-1 overflow-y-auto">
|
||||||
@@ -164,20 +133,9 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Theme toggle button
|
// Theme toggle button
|
||||||
<button
|
<div class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground hover:text-foreground transition-colors">
|
||||||
class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground hover:text-foreground transition-colors"
|
<crate::components::ui::theme_toggle::ThemeToggle />
|
||||||
on:click=toggle_theme
|
</div>
|
||||||
>
|
|
||||||
<Show when=move || current_theme.get() == "dark" fallback=|| view! {
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
|
||||||
</svg>
|
|
||||||
}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
|
||||||
</svg>
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
// Logout button
|
// Logout button
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent text-destructive transition-colors"
|
class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent text-destructive transition-colors"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod hooks;
|
||||||
pub mod context_menu;
|
pub mod context_menu;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod torrent;
|
pub mod torrent;
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ pub mod card;
|
|||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
pub mod context_menu;
|
pub mod context_menu;
|
||||||
|
pub mod theme_toggle;
|
||||||
|
pub mod svg_icon;
|
||||||
|
pub mod table;
|
||||||
25
frontend/src/components/ui/svg_icon.rs
Normal file
25
frontend/src/components/ui/svg_icon.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use tw_merge::tw_merge;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SvgIcon(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let class = tw_merge!("size-4", class);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class=class
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
}
|
||||||
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> }
|
||||||
|
}
|
||||||
76
frontend/src/components/ui/theme_toggle.rs
Normal file
76
frontend/src/components/ui/theme_toggle.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use crate::components::ui::svg_icon::SvgIcon;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::hooks::use_theme_mode::use_theme_mode;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ThemeToggle() -> impl IntoView {
|
||||||
|
let theme_mode = use_theme_mode();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<style>
|
||||||
|
{"
|
||||||
|
.theme__toggle_transition {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
|
svg path {
|
||||||
|
transform-origin: center;
|
||||||
|
transition: all .6s ease;
|
||||||
|
transform: translate3d(0,0,0);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
|
||||||
|
&.sun {
|
||||||
|
transform: scale(.4) rotate(60deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.moon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.switch {
|
||||||
|
svg path {
|
||||||
|
&.sun {
|
||||||
|
transform: scale(1) rotate(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.moon {
|
||||||
|
transform: scale(.4) rotate(-60deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
class=move || {
|
||||||
|
let base_class = "theme__toggle_transition";
|
||||||
|
if theme_mode.get() { format!("{base_class} switch") } else { base_class.to_string() }
|
||||||
|
}
|
||||||
|
on:click=move |_| theme_mode.toggle()
|
||||||
|
>
|
||||||
|
<SvgIcon class="size-4">
|
||||||
|
<path
|
||||||
|
d="M12 1.75V3.25M12 20.75V22.25M1.75 12H3.25M20.75 12H22.25M4.75216 4.75216L5.81282 5.81282M18.1872 18.1872L19.2478 19.2478M4.75216 19.2478L5.81282 18.1872M18.1872 5.81282L19.2478 4.75216M16.25 12C16.25 14.3472 14.3472 16.25 12 16.25C9.65279 16.25 7.75 14.3472 7.75 12C7.75 9.65279 9.65279 7.75 12 7.75C14.3472 7.75 16.25 9.65279 16.25 12Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
class="sun text-neutral-300"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C16.7154 21.25 20.6068 17.7216 21.1778 13.161C20.1198 13.8498 18.8566 14.25 17.5 14.25C13.7721 14.25 10.75 11.2279 10.75 7.5C10.75 5.66012 11.4861 3.99217 12.6799 2.77461C12.4554 2.7583 12.2287 2.75 12 2.75C6.89137 2.75 2.75 6.89137 2.75 12Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="moon text-neutral-700"
|
||||||
|
/>
|
||||||
|
</SvgIcon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user