qorzen_oxide/ui/layout/
header.rs

1// src/ui/layout/header.rs - Top navigation header with branding, user menu, and notifications
2
3use 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/// Header component props
13#[derive(Props, Clone, PartialEq)]
14pub struct HeaderProps {
15    /// Callback for mobile menu toggle
16    pub on_menu_toggle: Callback<()>,
17    /// Callback for sidebar toggle
18    pub on_sidebar_toggle: Callback<()>,
19}
20
21/// Main header component
22#[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    // State for dropdowns
29    let mut user_menu_open = use_signal(|| false);
30    let mut notifications_open = use_signal(|| false);
31
32    // Count unread notifications
33    let unread_count = notifications.iter().filter(|n| !n.read).count();
34
35    let left_side_mobile_button = rsx! {
36        // Mobile menu button
37        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            // Hamburger icon
46            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        // Desktop sidebar toggle
64        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            // Menu icon
73            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        // Logo
91        Link {
92            to: Route::Dashboard {},
93            class: "flex items-center",
94            div {
95                class: "flex-shrink-0 flex items-center",
96                // Logo placeholder - in real app this would be an image
97                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            // Left side - Logo and mobile menu button
114        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        // Search bar (desktop only)
124        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        // Bell icon
153        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        // Notification badge
170            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        // Header
180        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        // Notifications list
206        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        // Notifications dropdown
264        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            // Notifications dropdown
279            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        // User menu dropdown
294        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                // User avatar or initials
305                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            // User dropdown menu
333            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                            // User info
340                            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                            // Menu items
353                            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        // Right side - Search, notifications, user menu
393        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}