feat: improve toast animations and update to original Zinc color palette
All checks were successful
Build MIPS Binary / build (push) Successful in 5m21s
All checks were successful
Build MIPS Binary / build (push) Successful in 5m21s
This commit is contained in:
@@ -220,7 +220,7 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when=move || has_selection.get()>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="gap-2 bg-secondary text-secondary-foreground border-none hover:bg-secondary/80">
|
||||
<DropdownMenuTrigger class="w-[140px] h-9 gap-2">
|
||||
<Ellipsis class="size-4" />
|
||||
{move || format!("Toplu İşlem ({})", selected_count.get())}
|
||||
</DropdownMenuTrigger>
|
||||
@@ -610,4 +610,4 @@ fn TorrentCard(
|
||||
}
|
||||
</Show>
|
||||
}.into_any()
|
||||
}
|
||||
}
|
||||
|
||||
25
frontend/src/components/ui/drag_and_drop.rs
Normal file
25
frontend/src/components/ui/drag_and_drop.rs
Normal file
@@ -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! {
|
||||
<div class="p-4 border cursor-move border-input bg-card draggable" draggable="true">
|
||||
{text}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
145
frontend/src/components/ui/sonner.rs
Normal file
145
frontend/src/components/ui/sonner.rs
Normal file
@@ -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! {
|
||||
<button
|
||||
class=merged_class
|
||||
data-name="SonnerTrigger"
|
||||
data-variant=variant.to_string()
|
||||
data-toast-title=title
|
||||
data-toast-description=description
|
||||
data-toast-position=position_attr
|
||||
>
|
||||
{children()}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
#[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! {
|
||||
<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 {
|
||||
// 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! {
|
||||
<ol
|
||||
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()}
|
||||
</ol>
|
||||
}
|
||||
}
|
||||
|
||||
#[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! {
|
||||
<SonnerContainer class=container_class position=position>
|
||||
<SonnerList position=position direction=direction>
|
||||
""
|
||||
</SonnerList>
|
||||
</SonnerContainer>
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ pub struct ToastData {
|
||||
pub description: Option<String>,
|
||||
pub variant: ToastType,
|
||||
pub duration: u64, // ms
|
||||
pub is_exiting: RwSignal<bool>,
|
||||
}
|
||||
|
||||
#[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! { <span class="icon font-bold">"✓"</span> }.into_any()),
|
||||
ToastType::Error => Some(view! { <span class="icon font-bold">"✕"</span> }.into_any()),
|
||||
ToastType::Warning => Some(view! { <span class="icon font-bold">"⚠"</span> }.into_any()),
|
||||
ToastType::Info => Some(view! { <span class="icon font-bold">"ℹ"</span> }.into_any()),
|
||||
ToastType::Success => Some(view! { <span class="icon font-bold text-lg">"✓"</span> }.into_any()),
|
||||
ToastType::Error => Some(view! { <span class="icon font-bold text-lg">"✕"</span> }.into_any()),
|
||||
ToastType::Warning => Some(view! { <span class="icon font-bold text-lg">"⚠"</span> }.into_any()),
|
||||
ToastType::Info => Some(view! { <span class="icon font-bold text-lg">"ℹ"</span> }.into_any()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
view! {
|
||||
<div
|
||||
class=tw_merge!(
|
||||
class=move || tw_merge!(
|
||||
"absolute transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto overflow-hidden",
|
||||
"flex items-center gap-3 w-full max-w-[calc(100vw-2rem)] sm:max-w-[380px] p-4 rounded-lg border shadow-lg bg-card",
|
||||
"flex items-center gap-3 w-full max-w-[calc(100vw-2rem)] sm:max-w-[380px] p-4 rounded-lg border shadow-xl bg-card",
|
||||
if position.to_string().contains("Bottom") { "bottom-0" } else { "top-0" },
|
||||
variant_classes
|
||||
variant_classes,
|
||||
animation_class()
|
||||
)
|
||||
style=style
|
||||
on:click=move |_| {
|
||||
@@ -116,13 +131,13 @@ pub fn SonnerTrigger(
|
||||
>
|
||||
{icon}
|
||||
<div class="flex flex-col gap-0.5 overflow-hidden flex-1">
|
||||
<div class="text-sm font-semibold truncate leading-tight">{toast.title}</div>
|
||||
{move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-70 truncate">{d.clone()}</div> })}
|
||||
<div class="text-sm font-bold truncate leading-tight">{toast.title}</div>
|
||||
{move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-80 truncate">{d.clone()}</div> })}
|
||||
</div>
|
||||
|
||||
// Progress Bar
|
||||
<div
|
||||
class=tw_merge!("absolute bottom-0 left-0 h-1 w-full opacity-20", bar_color)
|
||||
class=tw_merge!("absolute bottom-0 left-0 h-1 w-full opacity-30", bar_color)
|
||||
style=format!(
|
||||
"animation: sonner-progress {}ms linear forwards; transform-origin: left;",
|
||||
toast.duration
|
||||
@@ -159,7 +174,15 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
|
||||
|
||||
view! {
|
||||
<style>
|
||||
"@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; }"
|
||||
</style>
|
||||
<div
|
||||
class=tw_merge!(
|
||||
@@ -179,6 +202,7 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
|
||||
children=move |(index, toast)| {
|
||||
let id = toast.id;
|
||||
let total = toasts.with(|t| t.len());
|
||||
let is_exiting = toast.is_exiting;
|
||||
|
||||
view! {
|
||||
<SonnerTrigger
|
||||
@@ -188,7 +212,11 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
|
||||
position=position
|
||||
is_expanded=is_hovered.into()
|
||||
on_dismiss=Callback::new(move |_| {
|
||||
toasts.update(|vec| vec.retain(|t| t.id != id));
|
||||
is_exiting.set(true);
|
||||
leptos::task::spawn_local(async move {
|
||||
gloo_timers::future::TimeoutFuture::new(300).await;
|
||||
toasts.update(|vec| vec.retain(|t| t.id != id));
|
||||
});
|
||||
})
|
||||
/>
|
||||
}
|
||||
@@ -209,6 +237,7 @@ pub fn toast(title: impl Into<String>, 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<String>, 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));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user