diff --git a/frontend/src/components/ui/toast.rs b/frontend/src/components/ui/toast.rs index d3acdf9..09dfaae 100644 --- a/frontend/src/components/ui/toast.rs +++ b/frontend/src/components/ui/toast.rs @@ -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, - #[prop(into, optional)] position: String, - on_dismiss: Option>, + toast: ToastData, + index: usize, + total: usize, + position: SonnerPosition, + #[prop(optional)] on_dismiss: Option>, ) -> 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! { "✓" }.into_any()), + ToastType::Error => Some(view! { "✕" }.into_any()), + ToastType::Warning => Some(view! { "⚠" }.into_any()), + ToastType::Info => Some(view! { "ℹ" }.into_any()), + _ => None, + }; view! {
-
{title}
- {move || description.as_ref().map(|d| view! {
{d.clone()}
})} + {icon} +
+
{toast.title}
+ {move || toast.description.as_ref().map(|d| view! {
{d.clone()}
})} +
- } + }.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! { -
- {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 { - let merged_class = tw_merge!( - "contents", - class - ); - - view! { -
- {children()} -
- } -} - -// Thread local storage for global access without Context +// Thread local storage for global access thread_local! { static TOASTS: std::cell::RefCell>>> = std::cell::RefCell::new(None); } pub fn provide_toaster() { let toasts = RwSignal::new(Vec::::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::().expect("Toaster context not found. Call provide_toaster() in App root."); + let store = use_context::().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! { - - - +
+ >() + } + 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! { +
+ +
} - /> - - - } + } + /> +
+ }.into_any() } // Global Helper Functions @@ -224,16 +196,18 @@ pub fn toast(title: impl Into, 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) { toast(title, ToastType::Error); } #[allow(dead_code)] pub fn toast_warning(title: impl Into) { toast(title, ToastType::Warning); } #[allow(dead_code)] -pub fn toast_info(title: impl Into) { toast(title, ToastType::Info); } \ No newline at end of file +pub fn toast_info(title: impl Into) { toast(title, ToastType::Info); }