skill_web/components/
notifications.rs

1//! Toast notification system
2//!
3//! Displays notifications in the top-right corner with auto-dismiss support.
4
5use gloo_timers::callback::Timeout;
6use yew::prelude::*;
7use yewdux::prelude::*;
8
9use crate::store::ui::{Notification, NotificationLevel, UiAction, UiStore};
10
11// ============================================================================
12// NotificationContainer - Renders all notifications
13// ============================================================================
14
15/// Container for all toast notifications
16#[function_component(NotificationContainer)]
17pub fn notification_container() -> Html {
18    let (store, _) = use_store::<UiStore>();
19    let notifications = store.visible_notifications();
20
21    html! {
22        <div
23            class="fixed top-4 right-4 z-50 flex flex-col gap-3 max-w-sm w-full pointer-events-none"
24            aria-live="polite"
25            aria-label="Notifications"
26        >
27            { for notifications.iter().map(|notification| {
28                html! {
29                    <NotificationToast
30                        key={notification.id.clone()}
31                        notification={notification.clone()}
32                    />
33                }
34            }) }
35        </div>
36    }
37}
38
39// ============================================================================
40// NotificationToast - Individual notification
41// ============================================================================
42
43#[derive(Properties, PartialEq)]
44struct NotificationToastProps {
45    notification: Notification,
46}
47
48#[function_component(NotificationToast)]
49fn notification_toast(props: &NotificationToastProps) -> Html {
50    let (_, dispatch) = use_store::<UiStore>();
51    let notification = &props.notification;
52    let is_exiting = use_state(|| false);
53
54    // Auto-dismiss timer
55    {
56        let id = notification.id.clone();
57        let auto_dismiss_ms = notification.auto_dismiss_ms;
58        let dispatch = dispatch.clone();
59        let is_exiting = is_exiting.clone();
60
61        use_effect_with(id.clone(), move |_| {
62            let cleanup: Option<Timeout> = if let Some(ms) = auto_dismiss_ms {
63                let timeout = Timeout::new(ms, move || {
64                    is_exiting.set(true);
65                    // Delay actual removal to allow exit animation
66                    let dispatch = dispatch.clone();
67                    let id = id.clone();
68                    Timeout::new(300, move || {
69                        dispatch.apply(UiAction::DismissNotification(id));
70                    })
71                    .forget();
72                });
73                Some(timeout)
74            } else {
75                None
76            };
77
78            move || {
79                drop(cleanup);
80            }
81        });
82    }
83
84    let on_dismiss = {
85        let dispatch = dispatch.clone();
86        let id = notification.id.clone();
87        let is_exiting = is_exiting.clone();
88        Callback::from(move |_| {
89            is_exiting.set(true);
90            let dispatch = dispatch.clone();
91            let id = id.clone();
92            Timeout::new(300, move || {
93                dispatch.apply(UiAction::DismissNotification(id));
94            })
95            .forget();
96        })
97    };
98
99    let (bg_class, border_class, icon_class) = match notification.level {
100        NotificationLevel::Info => (
101            "bg-blue-50 dark:bg-blue-900/30",
102            "border-blue-200 dark:border-blue-800",
103            "text-blue-500",
104        ),
105        NotificationLevel::Success => (
106            "bg-green-50 dark:bg-green-900/30",
107            "border-green-200 dark:border-green-800",
108            "text-green-500",
109        ),
110        NotificationLevel::Warning => (
111            "bg-amber-50 dark:bg-amber-900/30",
112            "border-amber-200 dark:border-amber-800",
113            "text-amber-500",
114        ),
115        NotificationLevel::Error => (
116            "bg-red-50 dark:bg-red-900/30",
117            "border-red-200 dark:border-red-800",
118            "text-red-500",
119        ),
120    };
121
122    let animation_class = if *is_exiting {
123        "animate-slide-out-right"
124    } else {
125        "animate-slide-in-right"
126    };
127
128    html! {
129        <div
130            class={classes!(
131                "pointer-events-auto",
132                "rounded-lg", "border", "shadow-lg",
133                "p-4", "flex", "gap-3",
134                bg_class, border_class,
135                animation_class
136            )}
137            role="alert"
138        >
139            // Icon
140            <div class={classes!("flex-shrink-0", "mt-0.5", icon_class)}>
141                <NotificationIcon level={notification.level.clone()} />
142            </div>
143
144            // Content
145            <div class="flex-1 min-w-0">
146                <h4 class="text-sm font-semibold text-gray-900 dark:text-white">
147                    { &notification.title }
148                </h4>
149                <p class="text-sm text-gray-600 dark:text-gray-300 mt-0.5">
150                    { &notification.message }
151                </p>
152
153                // Action button (optional)
154                if let Some(ref action_text) = notification.action_text {
155                    <button
156                        class="text-sm font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 mt-2"
157                    >
158                        { action_text }
159                    </button>
160                }
161            </div>
162
163            // Dismiss button
164            if notification.dismissible {
165                <button
166                    onclick={on_dismiss}
167                    class="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
168                    aria-label="Dismiss notification"
169                >
170                    <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
171                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
172                    </svg>
173                </button>
174            }
175        </div>
176    }
177}
178
179// ============================================================================
180// NotificationIcon - Icon based on level
181// ============================================================================
182
183#[derive(Properties, PartialEq)]
184struct NotificationIconProps {
185    level: NotificationLevel,
186}
187
188#[function_component(NotificationIcon)]
189fn notification_icon(props: &NotificationIconProps) -> Html {
190    match props.level {
191        NotificationLevel::Info => html! {
192            <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
193                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
194            </svg>
195        },
196        NotificationLevel::Success => html! {
197            <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
198                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
199            </svg>
200        },
201        NotificationLevel::Warning => html! {
202            <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
203                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
204            </svg>
205        },
206        NotificationLevel::Error => html! {
207            <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
208                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
209            </svg>
210        },
211    }
212}
213
214// ============================================================================
215// Hook for easy notification dispatch
216// ============================================================================
217
218/// Hook to easily show notifications from any component
219#[hook]
220pub fn use_notifications() -> UseNotificationsHandle {
221    let (_, dispatch) = use_store::<UiStore>();
222    UseNotificationsHandle { dispatch }
223}
224
225pub struct UseNotificationsHandle {
226    dispatch: Dispatch<UiStore>,
227}
228
229impl UseNotificationsHandle {
230    /// Show an info notification
231    pub fn info(&self, title: impl Into<String>, message: impl Into<String>) {
232        self.dispatch
233            .apply(UiAction::AddNotification(Notification::info(title, message)));
234    }
235
236    /// Show a success notification
237    pub fn success(&self, title: impl Into<String>, message: impl Into<String>) {
238        self.dispatch
239            .apply(UiAction::AddNotification(Notification::success(title, message)));
240    }
241
242    /// Show a warning notification
243    pub fn warning(&self, title: impl Into<String>, message: impl Into<String>) {
244        self.dispatch
245            .apply(UiAction::AddNotification(Notification::warning(title, message)));
246    }
247
248    /// Show an error notification
249    pub fn error(&self, title: impl Into<String>, message: impl Into<String>) {
250        self.dispatch
251            .apply(UiAction::AddNotification(Notification::error(title, message)));
252    }
253
254    /// Show a custom notification
255    pub fn show(&self, notification: Notification) {
256        self.dispatch.apply(UiAction::AddNotification(notification));
257    }
258
259    /// Dismiss a notification by ID
260    pub fn dismiss(&self, id: &str) {
261        self.dispatch
262            .apply(UiAction::DismissNotification(id.to_string()));
263    }
264
265    /// Clear all notifications
266    pub fn clear(&self) {
267        self.dispatch.apply(UiAction::ClearNotifications);
268    }
269}
270
271impl Clone for UseNotificationsHandle {
272    fn clone(&self) -> Self {
273        Self {
274            dispatch: self.dispatch.clone(),
275        }
276    }
277}