skill_web/components/
notifications.rs1use gloo_timers::callback::Timeout;
6use yew::prelude::*;
7use yewdux::prelude::*;
8
9use crate::store::ui::{Notification, NotificationLevel, UiAction, UiStore};
10
11#[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#[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 {
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 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 <div class={classes!("flex-shrink-0", "mt-0.5", icon_class)}>
141 <NotificationIcon level={notification.level.clone()} />
142 </div>
143
144 <div class="flex-1 min-w-0">
146 <h4 class="text-sm font-semibold text-gray-900 dark:text-white">
147 { ¬ification.title }
148 </h4>
149 <p class="text-sm text-gray-600 dark:text-gray-300 mt-0.5">
150 { ¬ification.message }
151 </p>
152
153 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 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#[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#[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 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 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 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 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 pub fn show(&self, notification: Notification) {
256 self.dispatch.apply(UiAction::AddNotification(notification));
257 }
258
259 pub fn dismiss(&self, id: &str) {
261 self.dispatch
262 .apply(UiAction::DismissNotification(id.to_string()));
263 }
264
265 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}