diff --git a/Cargo.lock b/Cargo.lock index 7887a70..90a6c95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1259,6 +1259,7 @@ dependencies = [ "gloo-console", "gloo-net", "gloo-timers", + "icons", "js-sys", "leptos", "leptos-use", @@ -1271,7 +1272,7 @@ dependencies = [ "serde_json", "shared", "struct-patch", - "strum", + "strum 0.26.3", "tailwind_fuse", "thiserror 2.0.18", "tw_merge", @@ -1864,6 +1865,17 @@ dependencies = [ "cc", ] +[[package]] +name = "icons" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75da3d473e24e0b541bf28cf31e67b262c379a4cbc2149f4865b7d99406711dc" +dependencies = [ + "leptos", + "strum 0.27.2", + "tw_merge", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -4137,7 +4149,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", ] [[package]] @@ -4153,6 +4174,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/frontend/input.css b/frontend/input.css index 89d9c96..c25e051 100644 --- a/frontend/input.css +++ b/frontend/input.css @@ -1,177 +1,92 @@ @import "tailwindcss"; -@config "./tailwind.config.js"; -@source "../src/**/*.rs"; -@source "/Users/bilal/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/leptos-shadcn-*/src/**/*.rs"; +@import "tw-animate-css"; -@theme { - /* Shadcn Colors */ - --color-border: hsl(var(--border)); - --color-input: hsl(var(--input)); - --color-ring: hsl(var(--ring)); - --color-background: hsl(var(--background)); - --color-foreground: hsl(var(--foreground)); - --color-primary: hsl(var(--primary)); - --color-primary-foreground: hsl(var(--primary-foreground)); +:root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; + --radius: 0.5rem; +} - --color-secondary: hsl(var(--secondary)); - --color-secondary-foreground: hsl(var(--secondary-foreground)); +.dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; +} - --color-destructive: hsl(var(--destructive)); - --color-destructive-foreground: hsl(var(--destructive-foreground)); - - --color-muted: hsl(var(--muted)); - --color-muted-foreground: hsl(var(--muted-foreground)); - - --color-accent: hsl(var(--accent)); - --color-accent-foreground: hsl(var(--accent-foreground)); - - --color-popover: hsl(var(--popover)); - --color-popover-foreground: hsl(var(--popover-foreground)); - - --color-card: hsl(var(--card)); - --color-card-foreground: hsl(var(--card-foreground)); - - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 4px); - - --animate-accordion-down: accordion-down 0.2s ease-out; - --animate-accordion-up: accordion-up 0.2s ease-out; - - @keyframes accordion-down { - from { - height: 0; - } - - to { - height: var(--radix-accordion-content-height); - } - } - - @keyframes accordion-up { - from { - height: var(--radix-accordion-content-height); - } - - to { - height: 0; - } - } +@theme inline { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } @layer base { - :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; - - --radius: 0.5rem; - } - - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - } + dialog { + margin: auto; + } } - -@layer base { - * { - @apply border-border; - } - - body { - @apply bg-background text-foreground; - } - - /* Ensure Shadcn Utilities are always available */ - .bg-popover { - background-color: hsl(var(--popover)); - } - - .text-popover-foreground { - color: hsl(var(--popover-foreground)); - } - - .border-border { - border-color: hsl(var(--border)); - } - - .shadow-md { - box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - } - - .z-50 { - z-index: 50; - } - - .z-100 { - z-index: 100; - } -} - -/* Fix for iOS click/blur events */ -@media (hover: none) { - body { - cursor: pointer; - } -} - -/* Remove default focus outline/tap highlight */ -* { - -webkit-tap-highlight-color: transparent; - outline: none !important; -} - -:focus { - outline: none !important; -} \ No newline at end of file diff --git a/frontend/public/hooks/lock_scroll.js b/frontend/public/hooks/lock_scroll.js new file mode 100644 index 0000000..48c04eb --- /dev/null +++ b/frontend/public/hooks/lock_scroll.js @@ -0,0 +1,253 @@ +/** + * Scroll Lock Utility + * Handles locking and unlocking scroll for both window and all scrollable containers + * Similar to react-remove-scroll but in vanilla JavaScript + */ + +(() => { + // Prevent multiple initializations + if (window.ScrollLock) { + return; + } + + class ScrollLock { + constructor() { + this.locked = false; + this.scrollableElements = []; + this.scrollPositions = new Map(); + this.originalStyles = new Map(); + this.fixedElements = []; + } + + /** + * Find all scrollable elements in the DOM (optimized) + * Uses more targeted selectors instead of querying all elements + */ + findScrollableElements() { + const scrollables = []; + + // More targeted query - only look for elements with overflow properties + const candidates = document.querySelectorAll( + '[style*="overflow"], [class*="overflow"], [class*="scroll"], main, aside, section, div', + ); + + // Batch all style reads first to minimize reflows + const elementsToCheck = []; + for (const el of candidates) { + // Skip the element itself or if it's inside these containers + const dataName = el.getAttribute("data-name"); + const isExcludedElement = + dataName === "ScrollArea" || + dataName === "CommandList" || + dataName === "SelectContent" || + dataName === "MultiSelectContent" || + dataName === "DropdownMenuContent" || + dataName === "ContextMenuContent"; + + if ( + el !== document.body && + el !== document.documentElement && + !isExcludedElement && + !el.closest('[data-name="ScrollArea"]') && + !el.closest('[data-name="CommandList"]') && + !el.closest('[data-name="SelectContent"]') && + !el.closest('[data-name="MultiSelectContent"]') && + !el.closest('[data-name="DropdownMenuContent"]') && + !el.closest('[data-name="ContextMenuContent"]') + ) { + elementsToCheck.push(el); + } + } + + // Now batch read all computed styles and dimensions + elementsToCheck.forEach((el) => { + const style = window.getComputedStyle(el); + const hasOverflow = + style.overflow === "auto" || + style.overflow === "scroll" || + style.overflowY === "auto" || + style.overflowY === "scroll"; + + // Only check scrollHeight if overflow is set + if (hasOverflow && el.scrollHeight > el.clientHeight) { + scrollables.push(el); + } + }); + + return scrollables; + } + + /** + * Lock scrolling on all scrollable elements (optimized) + * Batches all DOM reads before DOM writes to prevent forced reflows + */ + lock() { + if (this.locked) return; + + this.locked = true; + + // Find all scrollable elements + this.scrollableElements = this.findScrollableElements(); + + // ===== BATCH 1: READ PHASE - Read all layout properties first ===== + const windowScrollY = window.scrollY; + const scrollbarWidth = window.innerWidth - document.body.clientWidth; + + // Store window scroll position + this.scrollPositions.set("window", windowScrollY); + + // Store original body styles + this.originalStyles.set("body", { + position: document.body.style.position, + top: document.body.style.top, + width: document.body.style.width, + overflow: document.body.style.overflow, + paddingRight: document.body.style.paddingRight, + }); + + // Read all fixed-position elements and their padding (only if we have scrollbar) + if (scrollbarWidth > 0) { + // Use more targeted query for fixed elements + const fixedCandidates = document.querySelectorAll( + '[style*="fixed"], [class*="fixed"], header, nav, aside, [role="dialog"], [role="alertdialog"]', + ); + + this.fixedElements = Array.from(fixedCandidates).filter((el) => { + const style = window.getComputedStyle(el); + return ( + style.position === "fixed" && + !el.closest('[data-name="DropdownMenuContent"]') && + !el.closest('[data-name="MultiSelectContent"]') && + !el.closest('[data-name="ContextMenuContent"]') + ); + }); + + // Batch read all padding values + this.fixedElements.forEach((el) => { + const computedStyle = window.getComputedStyle(el); + const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0; + + this.originalStyles.set(el, { + paddingRight: el.style.paddingRight, + computedPadding: currentPadding, + }); + }); + } + + // Read scrollable elements info + const scrollableInfo = this.scrollableElements.map((el) => { + const scrollTop = el.scrollTop; + const elementScrollbarWidth = el.offsetWidth - el.clientWidth; + const computedStyle = window.getComputedStyle(el); + const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0; + + this.scrollPositions.set(el, scrollTop); + this.originalStyles.set(el, { + overflow: el.style.overflow, + overflowY: el.style.overflowY, + paddingRight: el.style.paddingRight, + }); + + return { el, elementScrollbarWidth, currentPadding }; + }); + + // ===== BATCH 2: WRITE PHASE - Apply all styles at once ===== + + // Apply body lock + document.body.style.position = "fixed"; + document.body.style.top = `-${windowScrollY}px`; + document.body.style.width = "100%"; + document.body.style.overflow = "hidden"; + + if (scrollbarWidth > 0) { + document.body.style.paddingRight = `${scrollbarWidth}px`; + + // Apply padding compensation to fixed elements + this.fixedElements.forEach((el) => { + const stored = this.originalStyles.get(el); + if (stored) { + el.style.paddingRight = `${stored.computedPadding + scrollbarWidth}px`; + } + }); + } + + // Lock all scrollable containers + scrollableInfo.forEach(({ el, elementScrollbarWidth, currentPadding }) => { + el.style.overflow = "hidden"; + + if (elementScrollbarWidth > 0) { + el.style.paddingRight = `${currentPadding + elementScrollbarWidth}px`; + } + }); + } + + /** + * Unlock scrolling on all elements (optimized) + * @param {number} delay - Delay in milliseconds before unlocking (for animations) + */ + unlock(delay = 0) { + if (!this.locked) return; + + const performUnlock = () => { + // Restore body scroll + const bodyStyles = this.originalStyles.get("body"); + if (bodyStyles) { + document.body.style.position = bodyStyles.position; + document.body.style.top = bodyStyles.top; + document.body.style.width = bodyStyles.width; + document.body.style.overflow = bodyStyles.overflow; + document.body.style.paddingRight = bodyStyles.paddingRight; + } + + // Restore window scroll position + const windowScrollY = this.scrollPositions.get("window") || 0; + window.scrollTo(0, windowScrollY); + + // Restore all scrollable containers + this.scrollableElements.forEach((el) => { + const originalStyles = this.originalStyles.get(el); + if (originalStyles) { + el.style.overflow = originalStyles.overflow; + el.style.overflowY = originalStyles.overflowY; + el.style.paddingRight = originalStyles.paddingRight; + } + + // Restore scroll position + const scrollPosition = this.scrollPositions.get(el) || 0; + el.scrollTop = scrollPosition; + }); + + // Restore fixed-position elements padding + this.fixedElements.forEach((el) => { + const styles = this.originalStyles.get(el); + if (styles && styles.paddingRight !== undefined) { + el.style.paddingRight = styles.paddingRight; + } + }); + + // Clear storage + this.scrollableElements = []; + this.fixedElements = []; + this.scrollPositions.clear(); + this.originalStyles.clear(); + this.locked = false; + }; + + if (delay > 0) { + setTimeout(performUnlock, delay); + } else { + performUnlock(); + } + } + + /** + * Check if scrolling is currently locked + */ + isLocked() { + return this.locked; + } + } + + // Export as singleton + window.ScrollLock = new ScrollLock(); +})(); diff --git a/frontend/src/components/torrent/table.rs b/frontend/src/components/torrent/table.rs index 0d5f17c..10941ab 100644 --- a/frontend/src/components/torrent/table.rs +++ b/frontend/src/components/torrent/table.rs @@ -220,7 +220,7 @@ pub fn TorrentTable() -> impl IntoView {
- + {move || format!("Toplu İşlem ({})", selected_count.get())} @@ -610,4 +610,4 @@ fn TorrentCard( } }.into_any() -} \ No newline at end of file +} diff --git a/frontend/src/components/ui/drag_and_drop.rs b/frontend/src/components/ui/drag_and_drop.rs new file mode 100644 index 0000000..2029507 --- /dev/null +++ b/frontend/src/components/ui/drag_and_drop.rs @@ -0,0 +1,25 @@ +use leptos::prelude::*; +use leptos_ui::clx; + +mod components { + use super::*; + clx! {Draggable, div, "flex flex-col gap-4 w-full max-w-4xl"} + clx! {DraggableZone, div, "dragabble__container", "bg-neutral-600 p-4 mt-4"} + + // TODO. ItemRoot (needs `draggable` as clx attribute). +} + +pub use components::*; + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +#[component] +pub fn DraggableItem(#[prop(into)] text: String) -> impl IntoView { + view! { +
+ {text} +
+ } +} \ No newline at end of file diff --git a/frontend/src/components/ui/sonner.rs b/frontend/src/components/ui/sonner.rs new file mode 100644 index 0000000..742ab05 --- /dev/null +++ b/frontend/src/components/ui/sonner.rs @@ -0,0 +1,145 @@ +use leptos::prelude::*; +use tw_merge::*; + +#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)] +pub enum ToastType { + #[default] + Default, + Success, + Error, + Warning, + Info, + Loading, +} + +#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)] +pub enum SonnerPosition { + TopLeft, + TopCenter, + TopRight, + #[default] + BottomRight, + BottomCenter, + BottomLeft, +} + +#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)] +pub enum SonnerDirection { + TopDown, + #[default] + BottomUp, +} + +#[component] +pub fn SonnerTrigger( + children: Children, + #[prop(into, optional)] class: String, + #[prop(optional, default = ToastType::default())] variant: ToastType, + #[prop(into)] title: String, + #[prop(into)] description: String, + #[prop(into, optional)] position: String, +) -> impl IntoView { + let variant_classes = match variant { + ToastType::Default => "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + ToastType::Success => "bg-success text-success-foreground hover:bg-success/90", + ToastType::Error => "bg-destructive text-white shadow-xs hover:bg-destructive/90 dark:bg-destructive/60", + ToastType::Warning => "bg-warning text-warning-foreground hover:bg-warning/90", + ToastType::Info => "bg-info text-info-foreground shadow-xs hover:bg-info/90", + ToastType::Loading => "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + }; + + let merged_class = tw_merge!( + "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 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] w-fit cursor-pointer h-9 px-4 py-2", + variant_classes, + class + ); + + // Only set position attribute if not empty + let position_attr = if position.is_empty() { None } else { Some(position) }; + + view! { + + } +} + +#[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-50", class); + + view! { +
+ {children()} +
+ } +} + +#[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 { + // pointer-events-none: container doesn't block clicks when empty + // [&>*]:pointer-events-auto: toast items still receive clicks + let merged_class = tw_merge!( + "flex relative flex-col opacity-100 gap-[15px] h-[100px] w-[400px] pointer-events-none [&>*]:pointer-events-auto", + class + ); + + view! { +
    + {children()} +
+ } +} + +#[component] +pub fn SonnerToaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView { + // Auto-derive direction from position + let direction = match position { + SonnerPosition::TopLeft | SonnerPosition::TopCenter | SonnerPosition::TopRight => SonnerDirection::TopDown, + _ => SonnerDirection::BottomUp, + }; + + let container_class = match position { + SonnerPosition::TopLeft => "left-6 top-6", + SonnerPosition::TopRight => "right-6 top-6", + SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-6", + SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-6", + SonnerPosition::BottomLeft => "left-6 bottom-6", + SonnerPosition::BottomRight => "right-6 bottom-6", + }; + + view! { + + + "" + + + } +} \ No newline at end of file diff --git a/frontend/src/components/ui/toast.rs b/frontend/src/components/ui/toast.rs index d8f32b1..d3a30cc 100644 --- a/frontend/src/components/ui/toast.rs +++ b/frontend/src/components/ui/toast.rs @@ -32,6 +32,7 @@ pub struct ToastData { pub description: Option, pub variant: ToastType, pub duration: u64, // ms + pub is_exiting: RwSignal, } #[derive(Clone, Copy)] @@ -72,13 +73,13 @@ pub fn SonnerTrigger( let (translate_y, scale, opacity) = if is_expanded.get() { // Expanded state: Full list layout - let y = index as f64 * 70.0; // height + gap + let y = index as f64 * 80.0; // Increased height + gap (y * y_direction, 1.0, 1.0) } else { // Stacked state: Sonner look - let y = index as f64 * 10.0; + let y = index as f64 * 12.0; let s = 1.0 - (index as f64 * 0.05); - let o = if index > 2 { 0.0 } else { 1.0 - (index as f64 * 0.2) }; + let o = if index > 3 { 0.0 } else { 1.0 - (index as f64 * 0.1) }; (y * y_direction, s, o) }; @@ -91,21 +92,35 @@ pub fn SonnerTrigger( ) }; + let animation_class = move || { + let pos = position.to_string(); + let is_left = pos.contains("Left"); + let is_exiting = toast.is_exiting.get(); + + match (is_left, is_exiting) { + (true, false) => "animate-sonner-in-left", + (true, true) => "animate-sonner-out-left", + (false, false) => "animate-sonner-in-right", + (false, true) => "animate-sonner-out-right", + } + }; + let icon = match toast.variant { - ToastType::Success => Some(view! { "✓" }.into_any()), - ToastType::Error => Some(view! { "✕" }.into_any()), - ToastType::Warning => Some(view! { "⚠" }.into_any()), - ToastType::Info => Some(view! { "ℹ" }.into_any()), + ToastType::Success => Some(view! { "✓" }.into_any()), + ToastType::Error => Some(view! { "✕" }.into_any()), + ToastType::Warning => Some(view! { "⚠" }.into_any()), + ToastType::Info => Some(view! { "ℹ" }.into_any()), _ => None, }; view! {
{icon}
-
{toast.title}
- {move || toast.description.as_ref().map(|d| view! {
{d.clone()}
})} +
{toast.title}
+ {move || toast.description.as_ref().map(|d| view! {
{d.clone()}
})}
// Progress Bar
- "@keyframes sonner-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }" + "@keyframes sonner-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } } + @keyframes sonner-in-right { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + @keyframes sonner-out-right { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } + @keyframes sonner-in-left { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + @keyframes sonner-out-left { from { transform: translateX(0); opacity: 1; } to { transform: translateX(-100%); opacity: 0; } } + .animate-sonner-in-right { animation: sonner-in-right 0.3s ease-out forwards; } + .animate-sonner-out-right { animation: sonner-out-right 0.3s ease-in forwards; } + .animate-sonner-in-left { animation: sonner-in-left 0.3s ease-out forwards; } + .animate-sonner-out-left { animation: sonner-out-left 0.3s ease-in forwards; }"
} @@ -209,6 +237,7 @@ pub fn toast(title: impl Into, variant: ToastType) { description: None, variant, duration: 4000, + is_exiting: RwSignal::new(false), }; toasts.update(|t| { @@ -219,8 +248,11 @@ pub fn toast(title: impl Into, variant: ToastType) { }); let duration = new_toast.duration; + let is_exiting = new_toast.is_exiting; leptos::task::spawn_local(async move { gloo_timers::future::TimeoutFuture::new(duration as u32).await; + is_exiting.set(true); + gloo_timers::future::TimeoutFuture::new(300).await; toasts.update(|vec| vec.retain(|t| t.id != id)); }); }