use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::{ BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub, BsGlobe2, BsGrid, BsHouseDoor, BsMoonFill, BsPuzzle, BsSunFill, }; use dioxus_free_icons::Icon; use crate::i18n::{t, Locale}; use crate::Route; /// Navigation entry for the sidebar. /// /// `key` is a stable identifier used for active-route detection and never /// changes across locales. `label` is the translated display string. struct NavItem { key: &'static str, label: String, route: Route, /// Bootstrap icon element rendered beside the label. icon: Element, } /// Fixed left sidebar containing header, navigation, logout, and footer. /// /// # Arguments /// /// * `name` - User display name (shown in header if non-empty). /// * `email` - Email address displayed beneath the avatar placeholder. /// * `avatar_url` - URL for the avatar image (unused placeholder for now). /// * `class` - CSS class override (e.g. to add `sidebar--open` on mobile). /// * `on_nav` - Callback fired when a nav link is clicked (used to close /// the mobile menu). #[component] pub fn Sidebar( name: String, email: String, avatar_url: String, #[props(default = "sidebar".to_string())] class: String, #[props(default)] on_nav: EventHandler<()>, ) -> Element { let locale = use_context::>(); let locale_val = *locale.read(); let nav_items: Vec = vec![ NavItem { key: "dashboard", label: t(locale_val, "nav.dashboard"), route: Route::DashboardPage {}, icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } }, }, NavItem { key: "providers", label: t(locale_val, "nav.providers"), route: Route::ProvidersPage {}, icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } }, }, NavItem { key: "chat", label: t(locale_val, "nav.chat"), route: Route::ChatPage {}, icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } }, }, NavItem { key: "tools", label: t(locale_val, "nav.tools"), route: Route::ToolsPage {}, icon: rsx! { Icon { icon: BsPuzzle, width: 18, height: 18 } }, }, NavItem { key: "knowledge_base", label: t(locale_val, "nav.knowledge_base"), route: Route::KnowledgePage {}, icon: rsx! { Icon { icon: BsCollection, width: 18, height: 18 } }, }, NavItem { key: "developer", label: t(locale_val, "nav.developer"), route: Route::AgentsPage {}, icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } }, }, NavItem { key: "organization", label: t(locale_val, "nav.organization"), route: Route::OrgPricingPage {}, icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } }, }, ]; // Determine current path to highlight the active nav link. let current_route = use_route::(); let logout_label = t(locale_val, "common.logout"); rsx! { aside { class: "{class}", div { class: "sidebar-top-row", SidebarHeader { name, email: email.clone(), avatar_url } LocalePicker {} } nav { class: "sidebar-nav", for item in nav_items { { // Active detection for nested routes: highlight the parent nav // item when any child route within the nested shell is active. let is_active = match ¤t_route { Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => { item.key == "developer" } Route::OrgPricingPage {} | Route::OrgDashboardPage {} => { item.key == "organization" } _ => item.route == current_route, }; let cls = if is_active { "sidebar-link active" } else { "sidebar-link" }; rsx! { Link { to: item.route, class: cls, onclick: move |_| on_nav.call(()), {item.icon} span { "{item.label}" } } } } } } div { class: "sidebar-bottom-actions", Link { to: NavigationTarget::::External("/logout".into()), class: "sidebar-link logout-btn", Icon { icon: BsBoxArrowRight, width: 18, height: 18 } span { "{logout_label}" } } ThemeToggle {} } SidebarFooter {} } } } /// Avatar circle, name, and email display at the top of the sidebar. /// /// # Arguments /// /// * `name` - User display name. If non-empty, shown above the email. /// * `email` - User email to display. /// * `avatar_url` - Placeholder for future avatar image URL. #[component] fn SidebarHeader(name: String, email: String, avatar_url: String) -> Element { // Derive initials: prefer name words, fall back to email prefix. let initials: String = if name.is_empty() { email .split('@') .next() .unwrap_or("U") .chars() .take(2) .collect::() .to_uppercase() } else { name.split_whitespace() .filter_map(|w| w.chars().next()) .take(2) .collect::() .to_uppercase() }; rsx! { div { class: "sidebar-header", div { class: "avatar-circle", span { class: "avatar-initials", "{initials}" } } div { class: "sidebar-user-info", if !name.is_empty() { p { class: "sidebar-name", "{name}" } } p { class: "sidebar-email", "{email}" } } } } } /// Toggle button that switches between dark and light themes. /// /// Sets `data-theme` on the `` element and persists the choice /// in `localStorage` so it survives page reloads. #[component] fn ThemeToggle() -> Element { let locale = use_context::>(); let mut is_dark = use_signal(|| { // Read persisted preference from localStorage on first render. #[cfg(feature = "web")] { web_sys::window() .and_then(|w| w.local_storage().ok().flatten()) .and_then(|s| s.get_item("theme").ok().flatten()) .is_none_or(|v| v != "certifai-light") } #[cfg(not(feature = "web"))] { true } }); // Apply the persisted theme to the DOM on first render so the // page doesn't flash dark if the user previously chose light. #[cfg(feature = "web")] { let dark = *is_dark.read(); use_effect(move || { let theme = if dark { "certifai-dark" } else { "certifai-light" }; if let Some(doc) = web_sys::window().and_then(|w| w.document()) { if let Some(el) = doc.document_element() { let _ = el.set_attribute("data-theme", theme); } } }); } let toggle = move |_| { let new_dark = !*is_dark.read(); is_dark.set(new_dark); #[cfg(feature = "web")] { let theme = if new_dark { "certifai-dark" } else { "certifai-light" }; if let Some(doc) = web_sys::window().and_then(|w| w.document()) { if let Some(el) = doc.document_element() { let _ = el.set_attribute("data-theme", theme); } } if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) { let _ = storage.set_item("theme", theme); } } }; let dark = *is_dark.read(); let locale_val = *locale.read(); let title = if dark { t(locale_val, "nav.switch_light") } else { t(locale_val, "nav.switch_dark") }; rsx! { button { class: "theme-toggle-btn", title: "{title}", onclick: toggle, if dark { Icon { icon: BsSunFill, width: 16, height: 16 } } else { Icon { icon: BsMoonFill, width: 16, height: 16 } } } } } /// Compact language picker with globe icon and ISO 3166-1 alpha-2 code. /// /// Renders a button showing a globe icon and the current locale's two-letter /// country code (e.g. "EN", "DE"). Clicking toggles a dropdown overlay with /// all available locales. Persists the selection to `localStorage`. #[component] fn LocalePicker() -> Element { let mut locale = use_context::>(); let current = *locale.read(); let mut open = use_signal(|| false); let mut select_locale = move |new_locale: Locale| { locale.set(new_locale); open.set(false); #[cfg(feature = "web")] { if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) { let _ = storage.set_item("certifai_locale", new_locale.code()); } } }; let code_upper = current.code().to_uppercase(); rsx! { div { class: "locale-picker", button { class: "locale-picker-btn", title: current.label(), onclick: move |_| { let cur = *open.read(); open.set(!cur); }, Icon { icon: BsGlobe2, width: 14, height: 14 } span { class: "locale-picker-code", "{code_upper}" } } if *open.read() { // Invisible backdrop to close dropdown on outside click div { class: "locale-picker-backdrop", onclick: move |_| open.set(false), } div { class: "locale-picker-dropdown", for loc in Locale::all() { { let is_active = *loc == current; let cls = if is_active { "locale-picker-item locale-picker-item--active" } else { "locale-picker-item" }; let loc_copy = *loc; rsx! { button { class: "{cls}", onclick: move |_| select_locale(loc_copy), span { class: "locale-picker-item-code", "{loc_copy.code().to_uppercase()}" } span { class: "locale-picker-item-label", "{loc_copy.label()}" } } } } } } } } } } /// Footer section with version string and placeholder social links. #[component] fn SidebarFooter() -> Element { let locale = use_context::>(); let locale_val = *locale.read(); let version = env!("CARGO_PKG_VERSION"); let github_title = t(locale_val, "nav.github"); let impressum_title = t(locale_val, "common.impressum"); let privacy_label = t(locale_val, "common.privacy_policy"); let impressum_label = t(locale_val, "common.impressum"); rsx! { footer { class: "sidebar-footer", div { class: "sidebar-social", a { href: "#", class: "social-link", title: "{github_title}", Icon { icon: BsGithub, width: 16, height: 16 } } a { href: "#", class: "social-link", title: "{impressum_title}", Icon { icon: BsGrid, width: 16, height: 16 } } } div { class: "sidebar-legal", Link { to: Route::PrivacyPage {}, class: "legal-link", "{privacy_label}" } span { class: "legal-sep", "|" } Link { to: Route::ImpressumPage {}, class: "legal-link", "{impressum_label}" } } p { class: "sidebar-version", "v{version}" } } } }