feat(i18n): add internationalization with DE, FR, ES, PT translations (#12)
Add a compile-time i18n system with 270 translation keys across 5 locales (EN, DE, FR, ES, PT). Translations are embedded via include_str! and parsed lazily into flat HashMaps with English fallback for missing keys. - Add src/i18n module with Locale enum, t()/tw() lookup functions, and tests - Add JSON translation files for all 5 locales under assets/i18n/ - Provide locale Signal via Dioxus context in App, persisted to localStorage - Replace all hardcoded UI strings across 33 component/page files - Add compact locale picker (globe icon + ISO alpha-2 code) in sidebar header - Add click-outside backdrop dismissal for locale dropdown Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
@@ -1,15 +1,20 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::{
|
||||
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub,
|
||||
BsGrid, BsHouseDoor, BsMoonFill, BsPuzzle, BsSunFill,
|
||||
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 {
|
||||
label: &'static str,
|
||||
key: &'static str,
|
||||
label: String,
|
||||
route: Route,
|
||||
/// Bootstrap icon element rendered beside the label.
|
||||
icon: Element,
|
||||
@@ -22,41 +27,60 @@ struct NavItem {
|
||||
/// * `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) -> Element {
|
||||
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::<Signal<Locale>>();
|
||||
let locale_val = *locale.read();
|
||||
|
||||
let nav_items: Vec<NavItem> = vec![
|
||||
NavItem {
|
||||
label: "Dashboard",
|
||||
key: "dashboard",
|
||||
label: t(locale_val, "nav.dashboard"),
|
||||
route: Route::DashboardPage {},
|
||||
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Providers",
|
||||
key: "providers",
|
||||
label: t(locale_val, "nav.providers"),
|
||||
route: Route::ProvidersPage {},
|
||||
icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Chat",
|
||||
key: "chat",
|
||||
label: t(locale_val, "nav.chat"),
|
||||
route: Route::ChatPage {},
|
||||
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Tools",
|
||||
key: "tools",
|
||||
label: t(locale_val, "nav.tools"),
|
||||
route: Route::ToolsPage {},
|
||||
icon: rsx! { Icon { icon: BsPuzzle, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Knowledge Base",
|
||||
key: "knowledge_base",
|
||||
label: t(locale_val, "nav.knowledge_base"),
|
||||
route: Route::KnowledgePage {},
|
||||
icon: rsx! { Icon { icon: BsCollection, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Developer",
|
||||
key: "developer",
|
||||
label: t(locale_val, "nav.developer"),
|
||||
route: Route::AgentsPage {},
|
||||
icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Organization",
|
||||
key: "organization",
|
||||
label: t(locale_val, "nav.organization"),
|
||||
route: Route::OrgPricingPage {},
|
||||
icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } },
|
||||
},
|
||||
@@ -64,10 +88,14 @@ pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element {
|
||||
|
||||
// Determine current path to highlight the active nav link.
|
||||
let current_route = use_route::<Route>();
|
||||
let logout_label = t(locale_val, "common.logout");
|
||||
|
||||
rsx! {
|
||||
aside { class: "sidebar",
|
||||
SidebarHeader { name, email: email.clone(), avatar_url }
|
||||
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 {
|
||||
@@ -76,16 +104,19 @@ pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element {
|
||||
// item when any child route within the nested shell is active.
|
||||
let is_active = match ¤t_route {
|
||||
Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => {
|
||||
item.label == "Developer"
|
||||
item.key == "developer"
|
||||
}
|
||||
Route::OrgPricingPage {} | Route::OrgDashboardPage {} => {
|
||||
item.label == "Organization"
|
||||
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,
|
||||
Link {
|
||||
to: item.route,
|
||||
class: cls,
|
||||
onclick: move |_| on_nav.call(()),
|
||||
{item.icon}
|
||||
span { "{item.label}" }
|
||||
}
|
||||
@@ -99,7 +130,7 @@ pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element {
|
||||
to: NavigationTarget::<Route>::External("/logout".into()),
|
||||
class: "sidebar-link logout-btn",
|
||||
Icon { icon: BsBoxArrowRight, width: 18, height: 18 }
|
||||
span { "Logout" }
|
||||
span { "{logout_label}" }
|
||||
}
|
||||
ThemeToggle {}
|
||||
}
|
||||
@@ -157,6 +188,8 @@ fn SidebarHeader(name: String, email: String, avatar_url: String) -> Element {
|
||||
/// in `localStorage` so it survives page reloads.
|
||||
#[component]
|
||||
fn ThemeToggle() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
|
||||
let mut is_dark = use_signal(|| {
|
||||
// Read persisted preference from localStorage on first render.
|
||||
#[cfg(feature = "web")]
|
||||
@@ -215,11 +248,17 @@ fn ThemeToggle() -> Element {
|
||||
};
|
||||
|
||||
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: if dark { "Switch to light mode" } else { "Switch to dark mode" },
|
||||
title: "{title}",
|
||||
onclick: toggle,
|
||||
if dark {
|
||||
Icon { icon: BsSunFill, width: 16, height: 16 }
|
||||
@@ -230,25 +269,106 @@ fn ThemeToggle() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::<Signal<Locale>>();
|
||||
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::<Signal<Locale>>();
|
||||
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",
|
||||
a { href: "#", class: "social-link", title: "{github_title}",
|
||||
Icon { icon: BsGithub, width: 16, height: 16 }
|
||||
}
|
||||
a { href: "#", class: "social-link", title: "Impressum",
|
||||
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 Policy" }
|
||||
Link { to: Route::PrivacyPage {}, class: "legal-link", "{privacy_label}" }
|
||||
span { class: "legal-sep", "|" }
|
||||
Link { to: Route::ImpressumPage {}, class: "legal-link", "Impressum" }
|
||||
Link { to: Route::ImpressumPage {}, class: "legal-link", "{impressum_label}" }
|
||||
}
|
||||
p { class: "sidebar-version", "v{version}" }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user