diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 1954622..9c38e0c 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -38,4 +38,7 @@ struct-patch = "0.5" # Rust/UI Components leptos_ui = "0.3" tw_merge = "0.1" -strum = { version = "0.26", features = ["derive"] } \ No newline at end of file +strum = { version = "0.26", features = ["derive"] } + +[package.metadata.leptos] +tailwind-input-file = "input.css" \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 3541cae..a676a9e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,14 +1,13 @@ { - "name": "frontend", - "version": "1.0.0", - "description": "", - "main": "tailwind.config.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "keywords": [], "author": "", - "license": "ISC", + "dependencies": { + "@tailwindcss/cli": "^4.1.18", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "tailwind-merge": "^3.4.0", + "tailwindcss-animate": "^1.0.7" + }, + "description": "", "devDependencies": { "@tailwindcss/postcss": "^4.1.18", "autoprefixer": "^10.4.23", @@ -17,11 +16,13 @@ "postcss-preset-env": "^10.1.3", "tailwindcss": "^4.1.18" }, - "dependencies": { - "@tailwindcss/cli": "^4.1.18", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "tailwind-merge": "^3.4.0", - "tailwindcss-animate": "^1.0.7" - } -} + "keywords": [], + "license": "ISC", + "main": "tailwind.config.js", + "name": "frontend", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "type": "module", + "version": "1.0.0" +} \ No newline at end of file diff --git a/frontend/src/app.rs b/frontend/src/app.rs index b1f891a..14e6908 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -6,20 +6,20 @@ use leptos::prelude::*; use leptos::task::spawn_local; use leptos_router::components::{Router, Routes, Route}; use leptos_router::hooks::use_navigate; -use crate::components::toast::Toaster; +use crate::components::ui::toast::Toaster; #[component] pub fn App() -> impl IntoView { + crate::components::ui::toast::provide_toaster(); view! { - + } } #[component] fn InnerApp() -> impl IntoView { crate::store::provide_torrent_store(); - crate::components::toast::provide_toast_context(); let store = use_context::(); let is_loading = signal(true); diff --git a/frontend/src/components/mod.rs b/frontend/src/components/mod.rs index 7541e03..b19887e 100644 --- a/frontend/src/components/mod.rs +++ b/frontend/src/components/mod.rs @@ -2,5 +2,5 @@ pub mod context_menu; pub mod layout; pub mod torrent; pub mod auth; -pub mod toast; +// pub mod toast; (Removed) pub mod ui; diff --git a/frontend/src/components/ui/mod.rs b/frontend/src/components/ui/mod.rs index 546f63a..f386bd2 100644 --- a/frontend/src/components/ui/mod.rs +++ b/frontend/src/components/ui/mod.rs @@ -1,3 +1,4 @@ pub mod button; pub mod card; pub mod input; +pub mod toast; diff --git a/frontend/src/components/ui/toast.rs b/frontend/src/components/ui/toast.rs new file mode 100644 index 0000000..901c936 --- /dev/null +++ b/frontend/src/components/ui/toast.rs @@ -0,0 +1,222 @@ +use leptos::prelude::*; +use tw_merge::*; + +#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)] +pub enum ToastType { + #[default] + Default, + Success, + Error, + Warning, + Info, + Loading, +} + +#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)] +pub enum SonnerPosition { + TopLeft, + TopCenter, + TopRight, + #[default] + BottomRight, + BottomCenter, + 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, + pub title: String, + pub description: Option, + pub variant: ToastType, + pub duration: u64, // ms +} + +#[derive(Clone, Copy)] +pub struct ToasterStore { + pub toasts: RwSignal>, +} + +#[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>, +) -> 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 merged_class = tw_merge!( + "inline-flex flex-col items-start justify-center gap-1 min-w-[300px] rounded-md text-sm font-medium transition-all shadow-lg p-4 cursor-pointer pointer-events-auto border border-border/50", + variant_classes, + class + ); + + // 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(); + + view! { +
+
{title}
+ {move || description.as_ref().map(|d| view! {
{d.clone()}
})} +
+ } +} + +#[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()} +
+ } +} + +pub fn provide_toaster() { + let toasts = RwSignal::new(Vec::::new()); + 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 toasts = store.toasts; + + // 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-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", + }; + + view! { + + + + } + } + /> + + + } +} + +// Global Helper Functions +pub fn toast(title: impl Into, variant: ToastType) { + if let Some(store) = use_context::() { + let id = js_sys::Math::random().to_bits(); + let new_toast = ToastData { + id, + title: title.into(), + description: None, + variant, + duration: 4000, + }; + + store.toasts.update(|t| t.push(new_toast.clone())); + + // Auto remove after duration + let duration = new_toast.duration; + leptos::task::spawn_local(async move { + gloo_timers::future::TimeoutFuture::new(duration as u32).await; + if let Some(store) = use_context::() { + store.toasts.update(|vec| vec.retain(|t| t.id != id)); + } + }); + } else { + gloo_console::warn!("ToasterStore not found. Make sure is mounted."); + } +} + +pub fn toast_success(title: impl Into) { toast(title, ToastType::Success); } +pub fn toast_error(title: impl Into) { toast(title, ToastType::Error); } +pub fn toast_warning(title: impl Into) { toast(title, ToastType::Warning); } +pub fn toast_info(title: impl Into) { toast(title, ToastType::Info); } \ No newline at end of file diff --git a/frontend/src/store.rs b/frontend/src/store.rs index f73c456..736f118 100644 --- a/frontend/src/store.rs +++ b/frontend/src/store.rs @@ -7,19 +7,21 @@ use std::collections::HashMap; use struct_patch::traits::Patch; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; -use crate::components::toast::ToastContext; +use crate::components::ui::toast::{ToastType, toast}; pub fn show_toast(level: NotificationLevel, message: impl Into) { let msg = message.into(); gloo_console::log!("TOAST CALL:", &msg, format!("{:?}", level)); log::info!("Displaying toast: [{:?}] {}", level, msg); - if let Some(context) = use_context::() { - context.add(msg, level); - } else { - log::error!("ToastContext not found!"); - gloo_console::error!("ToastContext not found!"); - } + let variant = match level { + NotificationLevel::Success => ToastType::Success, + NotificationLevel::Error => ToastType::Error, + NotificationLevel::Warning => ToastType::Warning, + NotificationLevel::Info => ToastType::Info, + }; + + toast(msg, variant); } diff --git a/frontend/ui_config.toml b/frontend/ui_config.toml new file mode 100644 index 0000000..564b780 --- /dev/null +++ b/frontend/ui_config.toml @@ -0,0 +1,2 @@ +base_color = "neutral" +base_path_components = "src/components"