Compare commits

..

8 Commits

Author SHA1 Message Date
spinline
8a7d9957aa fix: remove unused sonner module to resolve build errors
All checks were successful
Build MIPS Binary / build (push) Successful in 5m26s
2026-02-12 00:35:53 +03:00
spinline
56e8cc03d1 feat: implement official DataTable components and fix row spacing issues
Some checks failed
Build MIPS Binary / build (push) Failing after 1m33s
2026-02-12 00:32:58 +03:00
spinline
04cb7d51cb fix: resolve context menu positioning issue and integrate it into table rows
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 00:29:44 +03:00
spinline
555505b80e feat: implement real Sonner toast animations with stacking and hover expansion
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 00:25:57 +03:00
spinline
fa07fd88dc feat: modernize all buttons using the official rust-ui Button component
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-12 00:23:12 +03:00
spinline
bbb8e8dc98 fix: resolve compilation errors, fix lock_scroll path, and enhance DataTable attributes
All checks were successful
Build MIPS Binary / build (push) Successful in 5m27s
2026-02-12 00:15:17 +03:00
spinline
d09ecd21b7 feat: refactor torrent table to use DataTable components and fix context menu bugs
Some checks failed
Build MIPS Binary / build (push) Failing after 1m30s
2026-02-12 00:08:35 +03:00
spinline
9a00e341af feat: enhance context menu styles and add press-and-hold for destructive actions
Some checks failed
Build MIPS Binary / build (push) Failing after 1m31s
2026-02-12 00:03:32 +03:00
13 changed files with 717 additions and 300 deletions

View 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();
})();

View File

@@ -3,6 +3,8 @@ use leptos::task::spawn_local;
use crate::components::ui::card::{Card, CardHeader, CardContent};
use crate::components::ui::input::{Input, InputType};
use crate::components::ui::button::Button;
#[component]
pub fn Login() -> impl IntoView {
let username = RwSignal::new(String::new());
@@ -74,15 +76,16 @@ pub fn Login() -> impl IntoView {
</Show>
<div class="pt-2">
<button
class="inline-flex items-center justify-center w-full h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
disabled=move || loading.0.get()
<Button
class="w-full"
attr:r#type="submit"
attr:disabled=move || loading.0.get()
>
<Show when=move || loading.0.get() fallback=|| "Giriş Yap">
<Show when=move || loading.0.get() fallback=|| view! { "Giriş Yap" }.into_any()>
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
"Giriş Yapılıyor..."
</Show>
</button>
</Button>
</div>
</form>
</CardContent>

View File

@@ -3,6 +3,8 @@ use leptos::task::spawn_local;
use crate::components::ui::card::{Card, CardHeader, CardContent};
use crate::components::ui::input::{Input, InputType};
use crate::components::ui::button::Button;
#[component]
pub fn Setup() -> impl IntoView {
let username = RwSignal::new(String::new());
@@ -98,15 +100,16 @@ pub fn Setup() -> impl IntoView {
</Show>
<div class="pt-2">
<button
class="inline-flex items-center justify-center w-full h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
disabled=move || loading.0.get()
<Button
class="w-full"
attr:r#type="submit"
attr:disabled=move || loading.0.get()
>
<Show when=move || loading.0.get() fallback=|| "Kurulumu Tamamla">
<Show when=move || loading.0.get() fallback=|| view! { "Kurulumu Tamamla" }.into_any()>
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
"Kuruluyor..."
</Show>
</button>
</Button>
</div>
</form>
</CardContent>

View File

@@ -19,22 +19,31 @@ pub fn TorrentContextMenu(
{children()}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuAction on:click=move |_| menu_action("start")>
<ContextMenuContent class="w-56">
<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">
<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>
"Start"
</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">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
"Stop"
</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">
<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>
@@ -44,7 +53,7 @@ pub fn TorrentContextMenu(
<div class="-mx-1 my-1 h-px bg-border" />
<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")
>
<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"
</ContextMenuAction>
<ContextMenuAction
class="text-destructive hover:bg-destructive hover:text-destructive-foreground"
on:click=move |_| menu_action("delete_with_data")
<ContextMenuHoldAction
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
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">
<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>
"Remove with Data"
</ContextMenuAction>
<span class="ml-auto text-[10px] opacity-50">"Hold"</span>
</ContextMenuHoldAction>
</ContextMenuContent>
</ContextMenu>
}

View File

@@ -1,5 +1,6 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
#[component]
pub fn Sidebar() -> impl IntoView {
@@ -137,8 +138,11 @@ pub fn Sidebar() -> impl IntoView {
<crate::components::ui::theme_toggle::ThemeToggle />
</div>
// Logout button
<button
class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent text-destructive transition-colors"
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon
class="text-destructive hover:bg-destructive/10"
attr:disabled=move || false
on:click=move |_| {
spawn_local(async move {
if shared::server_fns::auth::logout().await.is_ok() {
@@ -151,7 +155,7 @@ pub fn Sidebar() -> impl IntoView {
<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="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
</button>
</Button>
</div>
</div>
</div>
@@ -166,13 +170,12 @@ fn SidebarButton(
#[prop(into)] label: &'static str,
count: Signal<usize>,
) -> impl IntoView {
let variant = move || if active.get() { ButtonVariant::Secondary } else { ButtonVariant::Ghost };
view! {
<button
class=move || if active.get() {
"inline-flex items-center justify-start gap-2 w-full h-8 rounded-md px-3 text-sm font-medium bg-secondary text-secondary-foreground transition-colors"
} else {
"inline-flex items-center justify-start gap-2 w-full h-8 rounded-md px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
}
<Button
variant=Signal::derive(variant)
class="justify-start gap-2 w-full h-8 px-3"
on:click=on_click
>
<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">
@@ -180,6 +183,6 @@ fn SidebarButton(
</svg>
{label}
<span class="ml-auto text-xs font-mono opacity-70">{count}</span>
</button>
</Button>
}
}

View File

@@ -4,6 +4,8 @@ use crate::components::ui::input::{Input, InputType};
use crate::store::TorrentStore;
use crate::api;
use crate::components::ui::button::{Button, ButtonVariant};
#[component]
pub fn AddTorrentDialog(
on_close: Callback<()>,
@@ -80,17 +82,16 @@ pub fn AddTorrentDialog(
})}
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
<button
type="button"
class="inline-flex items-center justify-center h-9 px-4 py-2 rounded-md text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
<Button
variant=ButtonVariant::Ghost
attr:r#type="button"
on:click=move |_| on_close.run(())
>
"Cancel"
</button>
<button
type="submit"
class="inline-flex items-center justify-center h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
disabled=move || is_loading.0.get()
</Button>
<Button
attr:r#type="submit"
attr:disabled=move || is_loading.0.get()
>
{move || if is_loading.0.get() {
leptos::either::Either::Left(view! {
@@ -100,13 +101,14 @@ pub fn AddTorrentDialog(
} else {
leptos::either::Either::Right(view! { "Add" })
}}
</button>
</Button>
</div>
</form>
// Close button (X)
<button
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none"
<Button
variant=ButtonVariant::Ghost
class="absolute right-2 top-2 size-8 p-0 opacity-70 hover:opacity-100"
on:click=move |_| on_close.run(())
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
@@ -114,7 +116,7 @@ pub fn AddTorrentDialog(
<path d="m6 6 12 12"></path>
</svg>
<span class="sr-only">"Close"</span>
</button>
</Button>
</div>
}
}

View File

@@ -4,7 +4,8 @@ use crate::store::{get_action_messages, show_toast};
use crate::api;
use shared::NotificationLevel;
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::data_table::*;
fn format_bytes(bytes: i64) -> String {
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| {
if sort_col.0.get() == col {
match sort_dir.0.get() {
SortDirection::Ascending => view! { <span class="ml-1 text-xs">""</span> }.into_any(),
SortDirection::Descending => 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-[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)| {
@@ -132,51 +133,53 @@ pub fn TorrentTable() -> impl IntoView {
});
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 ---
<div class="hidden md:flex flex-col h-full overflow-hidden">
// Header
<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 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Name)>
"Name" {move || sort_arrow(SortColumn::Name)}
<DataTableWrapper class="flex-1 min-h-0 bg-card/50">
<div class="h-full overflow-auto">
<DataTable>
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
<DataTableRow class="hover:bg-transparent">
<DataTableHead class="cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
<div class="flex items-center">"Name" {move || sort_arrow(SortColumn::Name)}</div>
</DataTableHead>
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Size)>
<div class="flex items-center">"Size" {move || sort_arrow(SortColumn::Size)}</div>
</DataTableHead>
<DataTableHead class="w-48 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Progress)>
<div class="flex items-center">"Progress" {move || sort_arrow(SortColumn::Progress)}</div>
</DataTableHead>
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Status)>
<div class="flex items-center">"Status" {move || sort_arrow(SortColumn::Status)}</div>
</DataTableHead>
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
<div class="flex items-center">"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}</div>
</DataTableHead>
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
<div class="flex items-center">"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}</div>
</DataTableHead>
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::ETA)>
<div class="flex items-center">"ETA" {move || sort_arrow(SortColumn::ETA)}</div>
</DataTableHead>
<DataTableHead class="w-32 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::AddedDate)>
<div class="flex items-center">"Date" {move || sort_arrow(SortColumn::AddedDate)}</div>
</DataTableHead>
</DataTableRow>
</DataTableHeader>
<DataTableBody>
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
let on_action = on_action.clone();
move |hash| {
view! {
<TorrentRow hash=hash.clone() on_action=on_action.clone() />
}
}
} />
</DataTableBody>
</DataTable>
</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)>
"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>
</DataTableWrapper>
</div>
// --- MOBILE VIEW ---
@@ -185,12 +188,9 @@ pub fn TorrentTable() -> impl IntoView {
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
let on_action = on_action.clone();
move |hash| {
let h = hash.clone();
view! {
<div class="pb-3">
<TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
<TorrentCard hash=hash.clone() />
</TorrentContextMenu>
<TorrentCard hash=hash.clone() on_action=on_action.clone() />
</div>
}
}
@@ -198,12 +198,13 @@ pub fn TorrentTable() -> impl IntoView {
</div>
</div>
</div>
}
}.into_any()
}
#[component]
fn TorrentRow(
hash: String,
on_action: Callback<(String, String)>,
) -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let h = hash.clone();
@@ -214,50 +215,70 @@ fn TorrentRow(
view! {
<Show when=move || torrent.get().is_some() fallback=|| ()>
{
let on_action = on_action.clone();
move || {
let t = torrent.get().unwrap();
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 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();
let h_for_menu = stored_hash.get_value();
view! {
<div
class=move || {
let selected = store.selected_torrent.get();
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()))
>
<div class="flex-1 min-w-0 px-2 font-medium truncate" title=t_name.clone()>{t_name.clone()}</div>
<div class="w-24 px-2 font-mono text-xs text-muted-foreground">{format_bytes(t.size)}</div>
<div class="w-48 px-2">
<div class="flex items-center gap-2">
<div class="h-2 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>
<TorrentContextMenu torrent_hash=h_for_menu on_action=on_action.clone()>
<DataTableRow
class="cursor-pointer group"
attr:data-state=move || if is_selected.get() { "selected" } else { "" }
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
>
<DataTableCell class="font-medium truncate max-w-[200px] lg:max-w-md" attr:title=t_name_for_title>
{t_name_for_content}
</DataTableCell>
<DataTableCell class="font-mono text-xs text-muted-foreground">
{format_bytes(t.size)}
</DataTableCell>
<DataTableCell>
<div class="flex items-center gap-2">
<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>
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>
</div>
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>
</div>
</div>
<div class={format!("w-24 px-2 text-xs font-medium {}", status_color)}>{format!("{:?}", t.status)}</div>
<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>
<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>
<div class="w-24 px-2 text-right font-mono text-xs text-muted-foreground">{format_duration(t.eta)}</div>
<div class="w-32 px-2 text-right font-mono text-xs text-muted-foreground">{format_date(t.added_date)}</div>
</div>
}
</DataTableCell>
<DataTableCell class={format!("text-xs font-semibold {}", status_color)}>
{format!("{:?}", t.status)}
</DataTableCell>
<DataTableCell class="text-right font-mono text-xs text-green-600 dark:text-green-500 whitespace-nowrap">
{format_speed(t.down_rate)}
</DataTableCell>
<DataTableCell class="text-right font-mono text-xs text-blue-600 dark:text-blue-500 whitespace-nowrap">
{format_speed(t.up_rate)}
</DataTableCell>
<DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
{format_duration(t.eta)}
</DataTableCell>
<DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
{format_date(t.added_date)}
</DataTableCell>
</DataTableRow>
</TorrentContextMenu>
}.into_any()
}
}
</Show>
}
}.into_any()
}
#[component]
fn TorrentCard(
hash: String,
on_action: Callback<(String, String)>,
) -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let h = hash.clone();
@@ -268,53 +289,57 @@ fn TorrentCard(
view! {
<Show when=move || torrent.get().is_some() fallback=|| ()>
{
let on_action = on_action.clone();
move || {
let t = torrent.get().unwrap();
let t_name = t.name.clone();
let status_badge_class = match t.status { shared::TorrentStatus::Seeding => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border-green-200 dark:border-green-800", shared::TorrentStatus::Downloading => "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800", shared::TorrentStatus::Paused => "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800", shared::TorrentStatus::Error => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800", _ => "bg-muted text-muted-foreground" };
let h_for_menu = stored_hash.get_value();
view! {
<div
class=move || {
let selected = store.selected_torrent.get();
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
if is_selected {
"ring-2 ring-primary rounded-lg transition-all"
} else {
"transition-all"
<TorrentContextMenu torrent_hash=h_for_menu on_action=on_action.clone()>
<div
class=move || {
let selected = store.selected_torrent.get();
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
if is_selected {
"ring-2 ring-primary rounded-lg transition-all"
} else {
"transition-all"
}
}
}
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
>
<Card class="h-full select-none cursor-pointer hover:border-primary transition-colors">
<CardHeader class="p-3 pb-0">
<div class="flex justify-between items-start gap-2">
<CardTitle class="text-sm font-medium leading-tight line-clamp-2">{t_name.clone()}</CardTitle>
<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>
</CardHeader>
<CardContent class="p-3 pt-2 gap-3 flex flex-col">
<div class="flex flex-col gap-1">
<div class="flex justify-between text-[10px] text-muted-foreground">
<span>{format_bytes(t.size)}</span>
<span>{format!("{:.1}%", t.percent_complete)}</span>
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
>
<Card class="h-full select-none cursor-pointer hover:border-primary transition-colors">
<CardHeader class="p-3 pb-0">
<div class="flex justify-between items-start gap-2">
<CardTitle class="text-sm font-medium leading-tight line-clamp-2">{t_name.clone()}</CardTitle>
<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 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>
</CardHeader>
<CardBody class="p-3 pt-2 gap-3 flex flex-col">
<div class="flex flex-col gap-1">
<div class="flex justify-between text-[10px] text-muted-foreground">
<span>{format_bytes(t.size)}</span>
<span>{format!("{:.1}%", t.percent_complete)}</span>
</div>
<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>
</div>
</div>
<div class="grid grid-cols-4 gap-2 text-[10px] font-mono text-muted-foreground pt-1 border-t border-border/50">
<div class="flex flex-col text-blue-600 dark:text-blue-500"><span>"DL"</span><span>{format_speed(t.down_rate)}</span></div>
<div class="flex flex-col text-green-600 dark:text-green-500"><span>"UP"</span><span>{format_speed(t.up_rate)}</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>
</CardContent>
</Card>
</div>
}
<div class="grid grid-cols-4 gap-2 text-[10px] font-mono text-muted-foreground pt-1 border-t border-border/50">
<div class="flex flex-col text-blue-600 dark:text-blue-500"><span>"DL"</span><span>{format_speed(t.down_rate)}</span></div>
<div class="flex flex-col text-green-600 dark:text-green-500"><span>"UP"</span><span>{format_speed(t.up_rate)}</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>
</CardBody>
</Card>
</div>
</TorrentContextMenu>
}.into_any()
}
}
</Show>
}
}
}.into_any()
}

View File

@@ -1,9 +1,11 @@
use leptos::prelude::*;
use leptos_ui::variants;
// TODO 💪 Loading state (demo_use_timeout_fn.rs and demo_button.rs)
variants! {
Button {
base: "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive w-fit hover:cursor-pointer active:scale-[0.98] active:opacity-100 touch-manipulation [-webkit-tap-highlight-color:transparent] select-none [-webkit-touch-callout:none]",
base: "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive w-fit hover:cursor-pointer active:scale-[0.98] active:opacity-100 touch-manipulation [-webkit-tap-highlight-color:transparent] select-none [-webkit-touch-callout:none]", // Using hover:cursor-pointer as workaround for href_support.
variants: {
variant: {
Default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
@@ -11,13 +13,21 @@ variants! {
Outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/5",
Secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
Ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
Accent: "bg-accent text-accent-foreground hover:bg-accent/80",
Link: "text-primary underline-offset-4 hover:underline",
//
Warning: "bg-warning text-warning-foreground hover:bg-warning/90",
Success: "bg-success text-success-foreground hover:bg-success/90",
Bordered: "bg-transparent border border-zinc-200 text-muted-foreground",
},
size: {
Default: "h-9 px-4 py-2 has-[>svg]:px-3",
Sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
Lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
Icon: "size-9",
//
Mobile: "px-6 py-3 rounded-[24px]",
Badge: "px-2.5 py-0.5 text-xs"
}
},
component: {
@@ -26,4 +36,4 @@ variants! {
support_aria_current: true
}
}
}
}

View File

@@ -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)]
struct ContextMenuContext {
target_id: String,
@@ -132,14 +202,14 @@ pub fn ContextMenuTrigger(
#[prop(optional)] on_open: Option<Callback<()>>,
) -> impl IntoView {
let ctx = expect_context::<ContextMenuContext>();
let trigger_class = tw_merge!("contents", class);
let trigger_class = tw_merge!("block w-full h-full", class);
view! {
<div
class=trigger_class
data-name="ContextMenuTrigger"
data-context-trigger=ctx.target_id
on:contextmenu=move |_| {
on:contextmenu=move |e: web_sys::MouseEvent| {
if let Some(cb) = on_open {
cb.run(());
}
@@ -160,14 +230,14 @@ pub fn ContextMenuContent(
) -> impl IntoView {
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 = "fixed 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 target_id_for_script = ctx.target_id.clone();
view! {
<script src="/hooks/lock_scroll.js"></script>
<script src="/lock_scroll.js"></script>
<div
data-name="ContextMenuContent"
@@ -329,7 +399,7 @@ pub fn ContextMenuContent(
target_id_for_script,
)}
</script>
}
}.into_any()
}
#[component]

View File

@@ -0,0 +1,6 @@
// * Reuse @table.rs
pub use crate::components::ui::table::{
Table as DataTable, TableBody as DataTableBody, TableCaption as DataTableCaption, TableCell as DataTableCell,
TableFooter as DataTableFooter, TableHead as DataTableHead, TableHeader as DataTableHeader,
TableRow as DataTableRow, TableWrapper as DataTableWrapper,
};

View File

@@ -5,3 +5,5 @@ pub mod toast;
pub mod context_menu;
pub mod theme_toggle;
pub mod svg_icon;
pub mod table;
pub mod data_table;

View File

@@ -0,0 +1,56 @@
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 w-full", 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 border-collapse", class);
view! { <table class=class>{children()}</table> }
}
#[component]
pub fn TableCaption(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("mt-4 text-sm text-muted-foreground", class);
view! { <caption class=class>{children()}</caption> }
}
#[component]
pub fn TableHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("[&_tr]:border-b bg-muted/50", 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-4 text-left align-middle font-medium text-muted-foreground whitespace-nowrap", 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 px-4 align-middle", class);
view! { <td class=class>{children()}</td> }
}
#[component]
pub fn TableFooter(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", class);
view! { <tfoot class=class>{children()}</tfoot> }
}

View File

@@ -25,13 +25,6 @@ pub enum SonnerPosition {
BottomLeft,
}
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
pub enum SonnerDirection {
TopDown,
#[default]
BottomUp,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ToastData {
pub id: u64,
@@ -48,166 +41,145 @@ pub struct ToasterStore {
#[component]
pub fn SonnerTrigger(
#[prop(into, optional)] class: String,
#[prop(optional, default = ToastType::default())] variant: ToastType,
#[prop(into)] title: String,
description: Option<String>,
#[prop(into, optional)] position: String,
on_dismiss: Option<Callback<()>>,
toast: ToastData,
index: usize,
total: usize,
position: SonnerPosition,
#[prop(optional)] on_dismiss: Option<Callback<()>>,
) -> impl IntoView {
let variant_classes = match variant {
ToastType::Default => "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
ToastType::Success => "bg-green-500 text-white hover:bg-green-600",
ToastType::Error => "bg-red-500 text-white shadow-xs hover:bg-red-600",
ToastType::Warning => "bg-yellow-500 text-white hover:bg-yellow-600",
ToastType::Info => "bg-blue-500 text-white shadow-xs hover:bg-blue-600",
ToastType::Loading => "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
let variant_classes = match toast.variant {
ToastType::Default => "bg-background text-foreground border-border",
ToastType::Success => "bg-background text-foreground border-border [&_.icon]:text-success",
ToastType::Error => "bg-background text-foreground border-border [&_.icon]:text-destructive",
ToastType::Warning => "bg-background text-foreground border-border [&_.icon]:text-warning",
ToastType::Info => "bg-background text-foreground border-border [&_.icon]:text-info",
ToastType::Loading => "bg-background text-foreground border-border",
};
let animation_direction = if position.contains("Top") {
"slide-in-from-top-5"
} else {
"slide-in-from-bottom-5"
};
// Sonner Stacking Logic
// We calculate inverse index: 0 is the newest (top), 1 is older, etc.
let inverse_index = index;
let offset = inverse_index as f64 * 16.0;
let scale = 1.0 - (inverse_index as f64 * 0.05);
let opacity = if inverse_index > 2 { 0.0 } else { 1.0 - (inverse_index as f64 * 0.2) };
let is_bottom = !position.to_string().contains("Top");
let y_direction = if is_bottom { -1.0 } else { 1.0 };
let translate_y = offset * y_direction;
let merged_class = tw_merge!(
"inline-flex flex-col items-start justify-center gap-1 min-w-[300px] rounded-md text-sm font-medium shadow-lg p-4 cursor-pointer pointer-events-auto border border-border/50 transition-all",
"animate-in fade-in duration-300 ease-out hover:scale-[1.02] active:scale-[0.98]",
animation_direction,
variant_classes,
class
let style = format!(
"z-index: {}; transform: translateY({}px) scale({}); opacity: {};",
total - index,
translate_y,
scale,
opacity
);
// Only set position attribute if not empty
let position_attr = if position.is_empty() { None } else { Some(position) };
// Clone title for data attribute usage, original moved into view
let title_clone = title.clone();
let icon = match toast.variant {
ToastType::Success => Some(view! { <span class="icon text-success">""</span> }.into_any()),
ToastType::Error => Some(view! { <span class="icon text-destructive">""</span> }.into_any()),
ToastType::Warning => Some(view! { <span class="icon text-warning">""</span> }.into_any()),
ToastType::Info => Some(view! { <span class="icon text-info">""</span> }.into_any()),
_ => None,
};
view! {
<div
class=merged_class
data-name="SonnerTrigger"
data-variant=variant.to_string()
data-toast-title=title_clone
data-toast-position=position_attr
class=tw_merge!(
"absolute transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto",
"flex items-center gap-3 min-w-[350px] p-4 rounded-lg border shadow-lg bg-card",
if is_bottom { "bottom-0" } else { "top-0" },
variant_classes
)
style=style
on:click=move |_| {
if let Some(cb) = on_dismiss {
cb.run(());
}
}
>
<div class="font-semibold">{title}</div>
{move || description.as_ref().map(|d| view! { <div class="text-xs opacity-90">{d.clone()}</div> })}
{icon}
<div class="flex flex-col gap-1">
<div class="text-sm font-semibold">{toast.title}</div>
{move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-70">{d.clone()}</div> })}
</div>
</div>
}
}.into_any()
}
#[component]
pub fn SonnerContainer(
children: Children,
#[prop(into, optional)] class: String,
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
) -> impl IntoView {
let merged_class = tw_merge!("toast__container fixed z-[9999] flex flex-col gap-2 p-4 outline-none pointer-events-none", class);
view! {
<div class=merged_class data-position=position.to_string()>
{children()}
</div>
}
}
#[component]
pub fn SonnerList(
children: Children,
#[prop(into, optional)] class: String,
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
#[prop(optional, default = SonnerDirection::default())] direction: SonnerDirection,
#[prop(into, default = "false".to_string())] expanded: String,
#[prop(into, optional)] style: String,
) -> impl IntoView {
let merged_class = tw_merge!(
"contents",
class
);
view! {
<div
class=merged_class
data-name="SonnerList"
data-sonner-toaster="true"
data-sonner-theme="light"
data-position=position.to_string()
data-expanded=expanded
data-direction=direction.to_string()
style=style
>
{children()}
</div>
}
}
// Thread local storage for global access without Context
// Thread local storage for global access
thread_local! {
static TOASTS: std::cell::RefCell<Option<RwSignal<Vec<ToastData>>>> = std::cell::RefCell::new(None);
}
pub fn provide_toaster() {
let toasts = RwSignal::new(Vec::<ToastData>::new());
// Set global thread_local
TOASTS.with(|t| *t.borrow_mut() = Some(toasts));
// Also provide context for components
provide_context(ToasterStore { toasts });
}
#[component]
pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
// Global store'u al
let store = use_context::<ToasterStore>().expect("Toaster context not found. Call provide_toaster() in App root.");
let store = use_context::<ToasterStore>().expect("Toaster context not found");
let toasts = store.toasts;
// Auto-derive direction from position
let direction = match position {
SonnerPosition::TopLeft | SonnerPosition::TopCenter | SonnerPosition::TopRight => SonnerDirection::TopDown,
_ => SonnerDirection::BottomUp,
};
let is_hovered = RwSignal::new(false);
let container_class = match position {
SonnerPosition::TopLeft => "left-0 top-0 items-start",
SonnerPosition::TopRight => "right-0 top-0 items-end",
SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-0 items-center",
SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-0 items-center",
SonnerPosition::BottomLeft => "left-0 bottom-0 items-start",
SonnerPosition::BottomRight => "right-0 bottom-0 items-end",
SonnerPosition::TopLeft => "left-6 top-6 items-start",
SonnerPosition::TopRight => "right-6 top-6 items-end",
SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-6 items-center",
SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-6 items-center",
SonnerPosition::BottomLeft => "left-6 bottom-6 items-start",
SonnerPosition::BottomRight => "right-6 bottom-6 items-end",
};
view! {
<SonnerContainer class=container_class position=position>
<SonnerList position=position direction=direction>
<For
each=move || toasts.get()
key=|toast| toast.id
children=move |toast| {
let id = toast.id;
view! {
<SonnerTrigger
variant=toast.variant
title=toast.title
description=toast.description
position=position.to_string()
on_dismiss=Some(Callback::new(move |_| {
toasts.update(|vec| vec.retain(|t| t.id != id));
}))
/>
<div
class=tw_merge!("fixed z-[100] flex flex-col pointer-events-none min-h-[200px] w-[400px]", container_class)
on:mouseenter=move |_| is_hovered.set(true)
on:mouseleave=move |_| is_hovered.set(false)
>
<For
each=move || {
let list = toasts.get();
// Reverse the list so newest is at the end (for stacking)
// or newest is at the beginning (for display logic)
list.into_iter().rev().enumerate().collect::<Vec<_>>()
}
key=|(_, toast)| toast.id
children=move |(index, toast)| {
let id = toast.id;
let total = toasts.with(|t| t.len());
// If hovered, expand the stack
let expanded_style = move || {
if is_hovered.get() {
let offset = index as f64 * 70.0;
let is_bottom = !position.to_string().contains("Top");
let y_dir = if is_bottom { -1.0 } else { 1.0 };
format!("transform: translateY({}px) scale(1); opacity: 1;", offset * y_dir)
} else {
"".to_string()
}
};
view! {
<div style=expanded_style>
<SonnerTrigger
toast=toast
index=index
total=total
position=position
on_dismiss=Callback::new(move |_| {
toasts.update(|vec| vec.retain(|t| t.id != id));
})
/>
</div>
}
/>
</SonnerList>
</SonnerContainer>
}
}
/>
</div>
}.into_any()
}
// Global Helper Functions
@@ -224,16 +196,18 @@ pub fn toast(title: impl Into<String>, variant: ToastType) {
duration: 4000,
};
toasts.update(|t| t.push(new_toast.clone()));
toasts.update(|t| {
t.push(new_toast.clone());
if t.len() > 5 {
t.remove(0);
}
});
// Auto remove after duration
let duration = new_toast.duration;
leptos::task::spawn_local(async move {
gloo_timers::future::TimeoutFuture::new(duration as u32).await;
toasts.update(|vec| vec.retain(|t| t.id != id));
});
} else {
gloo_console::warn!("ToasterStore not found (global static). Make sure provide_toaster() is called.");
}
}
@@ -244,4 +218,4 @@ pub fn toast_error(title: impl Into<String>) { toast(title, ToastType::Error); }
#[allow(dead_code)]
pub fn toast_warning(title: impl Into<String>) { toast(title, ToastType::Warning); }
#[allow(dead_code)]
pub fn toast_info(title: impl Into<String>) { toast(title, ToastType::Info); }
pub fn toast_info(title: impl Into<String>) { toast(title, ToastType::Info); }