1use dioxus::prelude::*;
4#[allow(unused_imports)]
5use dioxus_router::prelude::*;
6
7use crate::ui::{
8 router::Route,
9 state::{auth::use_logout, ui::use_notifications, use_app_state},
10};
11
12#[derive(Props, Clone, PartialEq)]
14pub struct HeaderProps {
15 pub on_menu_toggle: Callback<()>,
17 pub on_sidebar_toggle: Callback<()>,
19}
20
21#[component]
23pub fn Header(props: HeaderProps) -> Element {
24 let app_state = use_app_state();
25 let logout = use_logout();
26 let (notifications, remove_notification, mark_read, clear_all) = use_notifications();
27
28 let mut user_menu_open = use_signal(|| false);
30 let mut notifications_open = use_signal(|| false);
31
32 let unread_count = notifications.iter().filter(|n| !n.read).count();
34
35 let left_side_mobile_button = rsx! {
36 button {
38 r#type: "button",
39 class: "inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 lg:hidden",
40 onclick: move |_| props.on_menu_toggle.call(()),
41 span {
42 class: "sr-only",
43 "Open main menu"
44 }
45 svg {
47 class: "h-6 w-6",
48 xmlns: "http://www.w3.org/2000/svg",
49 fill: "none",
50 view_box: "0 0 24 24",
51 stroke: "currentColor",
52 path {
53 stroke_linecap: "round",
54 stroke_linejoin: "round",
55 stroke_width: "2",
56 d: "M4 6h16M4 12h16M4 18h16"
57 }
58 }
59 }
60 };
61
62 let left_side_desktop_sidebar_toggle = rsx! {
63 button {
65 r#type: "button",
66 class: "hidden lg:inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 mr-4",
67 onclick: move |_| props.on_sidebar_toggle.call(()),
68 span {
69 class: "sr-only",
70 "Toggle sidebar"
71 }
72 svg {
74 class: "h-5 w-5",
75 xmlns: "http://www.w3.org/2000/svg",
76 fill: "none",
77 view_box: "0 0 24 24",
78 stroke: "currentColor",
79 path {
80 stroke_linecap: "round",
81 stroke_linejoin: "round",
82 stroke_width: "2",
83 d: "M4 6h16M4 12h8m-8 6h16"
84 }
85 }
86 }
87 };
88
89 let left_side_logo = rsx! {
90 Link {
92 to: Route::Dashboard {},
93 class: "flex items-center",
94 div {
95 class: "flex-shrink-0 flex items-center",
96 div {
98 class: "h-8 w-8 bg-blue-600 rounded-lg flex items-center justify-center",
99 span {
100 class: "text-white font-bold text-sm",
101 "Q"
102 }
103 }
104 span {
105 class: "ml-2 text-xl font-bold text-gray-900 hidden sm:block",
106 "Qorzen"
107 }
108 }
109 }
110 };
111
112 let left_side = rsx! {
113 div {
115 class: "flex items-center",
116 {left_side_mobile_button}
117 {left_side_desktop_sidebar_toggle}
118 {left_side_logo}
119 }
120 };
121
122 let right_side_search_bar = rsx! {
123 div {
125 class: "hidden md:block",
126 div {
127 class: "relative",
128 input {
129 r#type: "text",
130 placeholder: "Search...",
131 class: "block w-64 pr-10 border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
132 }
133 div {
134 class: "absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none",
135 svg {
136 class: "h-5 w-5 text-gray-400",
137 xmlns: "http://www.w3.org/2000/svg",
138 view_box: "0 0 20 20",
139 fill: "currentColor",
140 path {
141 fill_rule: "evenodd",
142 d: "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z",
143 clip_rule: "evenodd"
144 }
145 }
146 }
147 }
148 }
149 };
150
151 let right_side_notifications_dropdown_bell_icon = rsx! {
152 svg {
154 class: "h-6 w-6",
155 xmlns: "http://www.w3.org/2000/svg",
156 fill: "none",
157 view_box: "0 0 24 24",
158 stroke: "currentColor",
159 path {
160 stroke_linecap: "round",
161 stroke_linejoin: "round",
162 stroke_width: "2",
163 d: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
164 }
165 }
166 };
167
168 let right_side_notifications_dropdown_notification_badge = rsx! {
169 if unread_count > 0 {
171 span {
172 class: "absolute -top-1 -right-1 h-5 w-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center",
173 "{unread_count}"
174 }
175 }
176 };
177
178 let right_side_notifications_dropdown_open_header = rsx! {
179 div {
181 class: "px-4 py-2 border-b border-gray-200 flex justify-between items-center",
182 h3 {
183 class: "text-sm font-medium text-gray-900",
184 "Notifications"
185 }
186 if !notifications.is_empty() {
187 button {
188 r#type: "button",
189 class: "text-xs text-blue-600 hover:text-blue-800",
190 onclick: move |_| {
191 clear_all.call(());
192 notifications_open.set(false);
193 },
194 "Clear all"
195 }
196 }
197 }
198 };
199
200 fn fmt_time(ts: chrono::DateTime<chrono::Utc>) -> String {
201 ts.format("%H:%M").to_string()
202 }
203
204 let right_side_notifications_dropdown_open_list = rsx! {
205 div {
207 class: "max-h-96 overflow-y-auto",
208 if notifications.is_empty() {
209 div {
210 class: "px-4 py-8 text-center text-sm text-gray-500",
211 "No notifications"
212 }
213 } else {
214 for notification in notifications.clone() {
215 div {
216 key: notification.id,
217 class: format!(
218 "px-4 py-3 hover:bg-gray-50 border-b border-gray-100 last:border-b-0 {}",
219 if notification.read { "opacity-75" } else { "" }
220 ),
221 div {
222 class: "flex justify-between items-start",
223 div {
224 class: "flex-1 min-w-0",
225 p {
226 class: "text-sm font-medium text-gray-900 truncate",
227 "{notification.title}"
228 }
229 p {
230 class: "text-sm text-gray-500 mt-1",
231 "{notification.message}"
232 }
233 p {
234 class: "text-xs text-gray-400 mt-1",
235 {fmt_time(notification.timestamp)}
236 }
237 }
238 div {
239 class: "flex space-x-1 ml-2",
240 if !notification.read {
241 button {
242 r#type: "button",
243 class: "text-xs text-blue-600 hover:text-blue-800",
244 onclick: move |_| mark_read.call(notification.id),
245 "Mark read"
246 }
247 }
248 button {
249 r#type: "button",
250 class: "text-xs text-red-600 hover:text-red-800",
251 onclick: move |_| remove_notification.call(notification.id),
252 "×"
253 }
254 }
255 }
256 }
257 }
258 }
259 }
260 };
261
262 let right_side_notifications_dropdown = rsx! {
263 div {
265 class: "relative",
266 button {
267 r#type: "button",
268 class: "relative p-2 text-gray-400 hover:text-gray-500 hover:bg-gray-100 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500",
269 onclick: move |_| notifications_open.set(!notifications_open()),
270 span {
271 class: "sr-only",
272 "View notifications"
273 }
274 {right_side_notifications_dropdown_bell_icon}
275 {right_side_notifications_dropdown_notification_badge}
276 }
277
278 if notifications_open() {
280 div {
281 class: "absolute right-0 mt-2 w-80 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-50",
282 div {
283 class: "py-1",
284 {right_side_notifications_dropdown_open_header}
285 {right_side_notifications_dropdown_open_list}
286 }
287 }
288 }
289 }
290 };
291
292 let user_menu_dropdown = rsx! {
293 div {
295 class: "relative",
296 button {
297 r#type: "button",
298 class: "flex items-center text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500",
299 onclick: move |_| user_menu_open.set(!user_menu_open()),
300 span {
301 class: "sr-only",
302 "Open user menu"
303 }
304 if let Some(user) = &app_state.current_user {
306 if let Some(avatar_url) = &user.profile.avatar_url {
307 img {
308 class: "h-8 w-8 rounded-full",
309 src: "{avatar_url}",
310 alt: "{user.profile.display_name}"
311 }
312 } else {
313 div {
314 class: "h-8 w-8 rounded-full bg-blue-600 flex items-center justify-center",
315 span {
316 class: "text-sm font-medium text-white",
317 "{user.profile.display_name.chars().next().unwrap_or('U')}"
318 }
319 }
320 }
321 } else {
322 div {
323 class: "h-8 w-8 rounded-full bg-gray-300 flex items-center justify-center",
324 span {
325 class: "text-sm font-medium text-gray-600",
326 "?"
327 }
328 }
329 }
330 }
331
332 if user_menu_open() {
334 div {
335 class: "absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-50",
336 div {
337 class: "py-1",
338 if let Some(user) = &app_state.current_user {
339 div {
341 class: "px-4 py-2 border-b border-gray-200",
342 p {
343 class: "text-sm font-medium text-gray-900",
344 "{user.profile.display_name}"
345 }
346 p {
347 class: "text-sm text-gray-500",
348 "{user.email}"
349 }
350 }
351
352 Link {
354 to: Route::Profile {},
355 class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
356 onclick: move |_| user_menu_open.set(false),
357 "👤 Profile"
358 }
359 Link {
360 to: Route::Settings {},
361 class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
362 onclick: move |_| user_menu_open.set(false),
363 "⚙️ Settings"
364 }
365 div {
366 class: "border-t border-gray-200"
367 }
368 button {
369 r#type: "button",
370 class: "block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
371 onclick: move |_| {
372 logout.call(());
373 user_menu_open.set(false);
374 },
375 "🚪 Sign out"
376 }
377 } else {
378 Link {
379 to: Route::Login {},
380 class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
381 onclick: move |_| user_menu_open.set(false),
382 "🔐 Sign in"
383 }
384 }
385 }
386 }
387 }
388 }
389 };
390
391 let right_side = rsx! {
392 div {
394 class: "flex items-center space-x-4",
395
396 {right_side_search_bar}
397 {right_side_notifications_dropdown}
398 {user_menu_dropdown}
399 }
400 };
401
402 rsx! {
403 header {
404 class: "bg-white shadow-sm border-b border-gray-200 relative z-50",
405 div {
406 class: "mx-auto max-w-full px-4 sm:px-6 lg:px-8",
407 div {
408 class: "flex justify-between items-center h-16",
409 {left_side}
410 {right_side}
411 }
412 }
413 }
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use dioxus::prelude::*;
421
422 #[test]
423 fn test_header_component_creation() {
424 let on_menu_toggle = Callback::new(|_| {});
425 let on_sidebar_toggle = Callback::new(|_| {});
426
427 let _header = rsx! {
428 Header {
429 on_menu_toggle: on_menu_toggle,
430 on_sidebar_toggle: on_sidebar_toggle
431 }
432 };
433 }
434}