feat(i18n): add internationalization with DE, FR, ES, PT translations (#12)
All checks were successful
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 3m4s
CI / Security Audit (push) Successful in 1m39s
CI / Tests (push) Successful in 4m26s
CI / Deploy (push) Successful in 5s

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:
2026-02-22 16:48:51 +00:00
parent 50237f5377
commit d814e22f9d
43 changed files with 3015 additions and 383 deletions

View File

@@ -1,6 +1,9 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{BsList, BsX};
use dioxus_free_icons::Icon;
use crate::components::sidebar::Sidebar;
use crate::i18n::{t, tw, Locale};
use crate::infrastructure::auth_check::check_auth;
use crate::models::AuthInfo;
use crate::Route;
@@ -12,6 +15,9 @@ use crate::Route;
/// sidebar with real user data and the active child route.
#[component]
pub fn AppShell() -> Element {
let locale = use_context::<Signal<Locale>>();
let mut mobile_menu_open = use_signal(|| false);
// use_resource memoises the async call and avoids infinite re-render
// loops that use_effect + spawn + signal writes can cause.
#[allow(clippy::redundant_closure)]
@@ -23,12 +29,44 @@ pub fn AppShell() -> Element {
match auth_snapshot {
Some(Ok(info)) if info.authenticated => {
let menu_open = *mobile_menu_open.read();
let sidebar_cls = if menu_open {
"sidebar sidebar--open"
} else {
"sidebar"
};
rsx! {
div { class: "app-shell",
// Mobile top bar (visible only on small screens via CSS)
header { class: "mobile-header",
button {
class: "mobile-menu-btn",
onclick: move |_| {
let current = *mobile_menu_open.read();
mobile_menu_open.set(!current);
},
if menu_open {
Icon { icon: BsX, width: 24, height: 24 }
} else {
Icon { icon: BsList, width: 24, height: 24 }
}
}
span { class: "mobile-header-title", "CERTifAI" }
}
// Backdrop overlay when sidebar is open on mobile
if menu_open {
div {
class: "sidebar-backdrop",
onclick: move |_| mobile_menu_open.set(false),
}
}
Sidebar {
email: info.email,
name: info.name,
avatar_url: info.avatar_url,
class: sidebar_cls,
on_nav: move |_| mobile_menu_open.set(false),
}
main { class: "main-content", Outlet::<Route> {} }
}
@@ -40,16 +78,17 @@ pub fn AppShell() -> Element {
nav.push(NavigationTarget::<Route>::External("/auth".into()));
rsx! {
div { class: "app-shell loading",
p { "Redirecting to login..." }
p { {t(*locale.read(), "auth.redirecting_login")} }
}
}
}
Some(Err(e)) => {
let msg = e.to_string();
let error_text = tw(*locale.read(), "auth.auth_error", &[("msg", &msg)]);
rsx! {
div { class: "auth-error",
p { "Authentication error: {msg}" }
a { href: "/auth", "Login" }
p { {error_text} }
a { href: "/auth", {t(*locale.read(), "common.login")} }
}
}
}
@@ -57,7 +96,7 @@ pub fn AppShell() -> Element {
// Still loading.
rsx! {
div { class: "app-shell loading",
p { "Loading..." }
p { {t(*locale.read(), "common.loading")} }
}
}
}

View File

@@ -1,6 +1,8 @@
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
use crate::infrastructure::llm::FollowUpMessage;
use crate::models::NewsCard;
use dioxus::prelude::*;
/// Side panel displaying the full details of a selected news article.
///
@@ -27,6 +29,9 @@ pub fn ArticleDetail(
#[props(default = false)] is_chatting: bool,
on_chat_send: EventHandler<String>,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let css_suffix = card.category.to_lowercase().replace(' ', "-");
let badge_class = format!("news-badge news-badge--{css_suffix}");
let mut chat_input = use_signal(String::new);
@@ -41,7 +46,7 @@ pub fn ArticleDetail(
button {
class: "article-detail-close",
onclick: move |_| on_close.call(()),
"X"
"{t(l, \"common.close\")}"
}
div { class: "article-detail-content",
@@ -74,7 +79,7 @@ pub fn ArticleDetail(
href: "{card.url}",
target: "_blank",
rel: "noopener",
"Read original article"
"{t(l, \"article.read_original\")}"
}
// AI Summary bubble (below the link)
@@ -82,11 +87,11 @@ pub fn ArticleDetail(
if is_summarizing {
div { class: "ai-summary-bubble-loading",
div { class: "ai-summary-dot-pulse" }
span { "Summarizing..." }
span { "{t(l, \"article.summarizing\")}" }
}
} else if let Some(ref text) = summary {
p { class: "ai-summary-bubble-text", "{text}" }
span { class: "ai-summary-bubble-label", "Summarized with AI" }
span { class: "ai-summary-bubble-label", "{t(l, \"article.summarized_with_ai\")}" }
}
}
@@ -123,7 +128,7 @@ pub fn ArticleDetail(
input {
class: "article-chat-textbox",
r#type: "text",
placeholder: "Ask a follow-up question...",
placeholder: "{t(l, \"article.ask_followup\")}",
value: "{chat_input}",
disabled: is_chatting,
oninput: move |e| chat_input.set(e.value()),
@@ -147,7 +152,7 @@ pub fn ArticleDetail(
chat_input.set(String::new());
}
},
"Send"
"{t(l, \"common.send\")}"
}
}
}

View File

@@ -1,3 +1,4 @@
use crate::i18n::{t, Locale};
use dioxus::prelude::*;
use dioxus_free_icons::icons::fa_solid_icons::{FaCopy, FaPenToSquare, FaShareNodes};
@@ -22,6 +23,9 @@ pub fn ChatActionBar(
has_assistant_message: bool,
has_user_message: bool,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
if !has_messages {
return rsx! {};
}
@@ -31,34 +35,34 @@ pub fn ChatActionBar(
button {
class: "chat-action-btn",
disabled: !has_assistant_message,
title: "Copy last response",
title: "{t(l, \"chat.copy_response\")}",
onclick: move |_| on_copy.call(()),
dioxus_free_icons::Icon {
icon: FaCopy,
width: 14, height: 14,
}
span { class: "chat-action-label", "Copy" }
span { class: "chat-action-label", "{t(l, \"common.copy\")}" }
}
button {
class: "chat-action-btn",
title: "Copy conversation",
title: "{t(l, \"chat.copy_conversation\")}",
onclick: move |_| on_share.call(()),
dioxus_free_icons::Icon {
icon: FaShareNodes,
width: 14, height: 14,
}
span { class: "chat-action-label", "Share" }
span { class: "chat-action-label", "{t(l, \"common.share\")}" }
}
button {
class: "chat-action-btn",
disabled: !has_user_message,
title: "Edit last message",
title: "{t(l, \"chat.edit_last\")}",
onclick: move |_| on_edit.call(()),
dioxus_free_icons::Icon {
icon: FaPenToSquare,
width: 14, height: 14,
}
span { class: "chat-action-label", "Edit" }
span { class: "chat-action-label", "{t(l, \"common.edit\")}" }
}
}
}

View File

@@ -1,3 +1,4 @@
use crate::i18n::{t, Locale};
use crate::models::{ChatMessage, ChatRole};
use dioxus::prelude::*;
@@ -35,6 +36,9 @@ fn markdown_to_html(md: &str) -> String {
/// * `message` - The chat message to render
#[component]
pub fn ChatBubble(message: ChatMessage) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// System messages are not rendered in the UI
if message.role == ChatRole::System {
return rsx! {};
@@ -47,8 +51,8 @@ pub fn ChatBubble(message: ChatMessage) -> Element {
};
let role_label = match message.role {
ChatRole::User => "You",
ChatRole::Assistant => "Assistant",
ChatRole::User => t(l, "chat.you"),
ChatRole::Assistant => t(l, "chat.assistant"),
ChatRole::System => unreachable!(),
};
@@ -99,6 +103,9 @@ pub fn ChatBubble(message: ChatMessage) -> Element {
/// * `content` - The accumulated streaming content so far
#[component]
pub fn StreamingBubble(content: String) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
if content.is_empty() {
// Thinking state -- no tokens yet
rsx! {
@@ -109,7 +116,9 @@ pub fn StreamingBubble(content: String) -> Element {
span { class: "chat-dot" }
span { class: "chat-dot" }
}
span { class: "chat-thinking-text", "Thinking..." }
span { class: "chat-thinking-text",
"{t(l, \"chat.thinking\")}"
}
}
}
}
@@ -118,7 +127,9 @@ pub fn StreamingBubble(content: String) -> Element {
rsx! {
div { class: "chat-bubble chat-bubble--assistant chat-bubble--streaming",
div { class: "chat-bubble-header",
span { class: "chat-bubble-role", "Assistant" }
span { class: "chat-bubble-role",
"{t(l, \"chat.assistant\")}"
}
}
div {
class: "chat-bubble-content chat-prose",

View File

@@ -1,3 +1,4 @@
use crate::i18n::{t, Locale};
use dioxus::prelude::*;
/// Chat input bar with a textarea and send button.
@@ -16,13 +17,16 @@ pub fn ChatInputBar(
on_send: EventHandler<String>,
is_streaming: bool,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let mut input = input_text;
rsx! {
div { class: "chat-input-bar",
textarea {
class: "chat-input",
placeholder: "Type a message...",
placeholder: "{t(l, \"chat.type_message\")}",
disabled: is_streaming,
rows: "1",
value: "{input}",

View File

@@ -1,4 +1,5 @@
use crate::components::{ChatBubble, StreamingBubble};
use crate::i18n::{t, Locale};
use crate::models::ChatMessage;
use dioxus::prelude::*;
@@ -18,13 +19,16 @@ pub fn ChatMessageList(
streaming_content: String,
is_streaming: bool,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
div {
class: "chat-message-list",
id: "chat-message-list",
if messages.is_empty() && !is_streaming {
div { class: "chat-empty",
p { "Send a message to start the conversation." }
p { "{t(l, \"chat.send_to_start\")}" }
}
}
for msg in &messages {

View File

@@ -1,3 +1,4 @@
use crate::i18n::{t, Locale};
use dioxus::prelude::*;
/// Dropdown bar for selecting the LLM model for the current chat session.
@@ -17,9 +18,14 @@ pub fn ChatModelSelector(
available_models: Vec<String>,
on_change: EventHandler<String>,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
div { class: "chat-model-bar",
label { class: "chat-model-label", "Model:" }
label { class: "chat-model-label",
"{t(l, \"chat.model_label\")}"
}
select {
class: "chat-model-select",
value: "{selected_model}",
@@ -34,7 +40,9 @@ pub fn ChatModelSelector(
}
}
if available_models.is_empty() {
option { disabled: true, "No models available" }
option { disabled: true,
"{t(l, \"chat.no_models\")}"
}
}
}
}

View File

@@ -1,3 +1,4 @@
use crate::i18n::{t, tw, Locale};
use crate::models::{ChatNamespace, ChatSession};
use dioxus::prelude::*;
@@ -24,6 +25,9 @@ pub fn ChatSidebar(
on_rename: EventHandler<(String, String)>,
on_delete: EventHandler<String>,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// Split sessions by namespace
let news_sessions: Vec<&ChatSession> = sessions
.iter()
@@ -40,10 +44,10 @@ pub fn ChatSidebar(
rsx! {
div { class: "chat-sidebar-panel",
div { class: "chat-sidebar-header",
h3 { "Conversations" }
h3 { "{t(l, \"chat.conversations\")}" }
button {
class: "btn-icon",
title: "New Chat",
title: "{t(l, \"chat.new_chat\")}",
onclick: move |_| on_new.call(()),
"+"
}
@@ -51,7 +55,9 @@ pub fn ChatSidebar(
div { class: "chat-session-list",
// News Chats section
if !news_sessions.is_empty() {
div { class: "chat-namespace-header", "News Chats" }
div { class: "chat-namespace-header",
"{t(l, \"chat.news_chats\")}"
}
for session in &news_sessions {
SessionItem {
session: (*session).clone(),
@@ -66,10 +72,16 @@ pub fn ChatSidebar(
// General section
div { class: "chat-namespace-header",
if news_sessions.is_empty() { "All Chats" } else { "General" }
if news_sessions.is_empty() {
"{t(l, \"chat.all_chats\")}"
} else {
"{t(l, \"chat.general\")}"
}
}
if general_sessions.is_empty() {
p { class: "chat-empty-hint", "No conversations yet" }
p { class: "chat-empty-hint",
"{t(l, \"chat.no_conversations\")}"
}
}
for session in &general_sessions {
SessionItem {
@@ -96,6 +108,9 @@ fn SessionItem(
on_rename: EventHandler<(String, String)>,
on_delete: EventHandler<String>,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let mut rename_sig = rename_state;
let item_class = if is_active {
"chat-session-item chat-session-item--active"
@@ -110,7 +125,7 @@ fn SessionItem(
let session_id = session.id.clone();
let session_title = session.title.clone();
let date_display = format_relative_date(&session.updated_at);
let date_display = format_relative_date(&session.updated_at, l);
if is_renaming {
let rename_value = rename_sig
@@ -172,7 +187,7 @@ fn SessionItem(
div { class: "chat-session-actions",
button {
class: "btn-icon-sm",
title: "Rename",
title: "{t(l, \"common.rename\")}",
onclick: move |e: Event<MouseData>| {
e.stop_propagation();
rename_sig.set(Some((
@@ -187,7 +202,7 @@ fn SessionItem(
}
button {
class: "btn-icon-sm btn-icon-danger",
title: "Delete",
title: "{t(l, \"common.delete\")}",
onclick: move |e: Event<MouseData>| {
e.stop_propagation();
on_delete.call(sid_delete.clone());
@@ -204,19 +219,36 @@ fn SessionItem(
}
/// Format an ISO 8601 timestamp as a relative date string.
fn format_relative_date(iso: &str) -> String {
///
/// # Arguments
///
/// * `iso` - ISO 8601 timestamp string
/// * `locale` - The locale to use for translated time labels
fn format_relative_date(iso: &str, locale: Locale) -> String {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) {
let now = chrono::Utc::now();
let diff = now.signed_duration_since(dt);
if diff.num_minutes() < 1 {
"just now".to_string()
t(locale, "chat.just_now")
} else if diff.num_hours() < 1 {
format!("{}m ago", diff.num_minutes())
tw(
locale,
"chat.minutes_ago",
&[("n", &diff.num_minutes().to_string())],
)
} else if diff.num_hours() < 24 {
format!("{}h ago", diff.num_hours())
tw(
locale,
"chat.hours_ago",
&[("n", &diff.num_hours().to_string())],
)
} else if diff.num_days() < 7 {
format!("{}d ago", diff.num_days())
tw(
locale,
"chat.days_ago",
&[("n", &diff.num_days().to_string())],
)
} else {
dt.format("%b %d").to_string()
}

View File

@@ -1,5 +1,6 @@
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
use crate::infrastructure::ollama::{get_ollama_status, OllamaStatus};
/// Right sidebar for the dashboard, showing Ollama status, trending topics,
@@ -21,6 +22,9 @@ pub fn DashboardSidebar(
recent_searches: Vec<String>,
on_topic_click: EventHandler<String>,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// Fetch Ollama status once on mount.
// use_resource with no signal dependencies runs exactly once and
// won't re-fire on parent re-renders (unlike use_effect).
@@ -50,14 +54,14 @@ pub fn DashboardSidebar(
// -- Ollama Status Section --
div { class: "sidebar-section",
h4 { class: "sidebar-section-title", "Ollama Status" }
h4 { class: "sidebar-section-title", "{t(l, \"dashboard.ollama_status\")}" }
div { class: "sidebar-status-row",
span { class: if current_status.online { "sidebar-status-dot sidebar-status-dot--online" } else { "sidebar-status-dot sidebar-status-dot--offline" } }
span { class: "sidebar-status-label",
if current_status.online {
"Online"
"{t(l, \"common.online\")}"
} else {
"Offline"
"{t(l, \"common.offline\")}"
}
}
}
@@ -73,7 +77,7 @@ pub fn DashboardSidebar(
// -- Trending Topics Section --
if !trending.is_empty() {
div { class: "sidebar-section",
h4 { class: "sidebar-section-title", "Trending" }
h4 { class: "sidebar-section-title", "{t(l, \"dashboard.trending\")}" }
for topic in trending.iter() {
{
let t = topic.clone();
@@ -92,7 +96,7 @@ pub fn DashboardSidebar(
// -- Recent Searches Section --
if !recent_searches.is_empty() {
div { class: "sidebar-section",
h4 { class: "sidebar-section-title", "Recent Searches" }
h4 { class: "sidebar-section-title", "{t(l, \"dashboard.recent_searches\")}" }
for search in recent_searches.iter() {
{
let s = search.clone();

View File

@@ -1,6 +1,8 @@
use crate::models::KnowledgeFile;
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
use crate::models::KnowledgeFile;
/// Renders a table row for a knowledge base file.
///
/// # Arguments
@@ -9,6 +11,9 @@ use dioxus::prelude::*;
/// * `on_delete` - Callback fired when the delete button is clicked
#[component]
pub fn FileRow(file: KnowledgeFile, on_delete: EventHandler<String>) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// Format file size for human readability (Python devs: similar to humanize.naturalsize)
let size_display = format_size(file.size_bytes);
@@ -20,7 +25,7 @@ pub fn FileRow(file: KnowledgeFile, on_delete: EventHandler<String>) -> Element
}
td { "{file.kind.label()}" }
td { "{size_display}" }
td { "{file.chunk_count} chunks" }
td { "{file.chunk_count} {t(l, \"common.chunks\")}" }
td { "{file.uploaded_at}" }
td {
button {
@@ -29,7 +34,7 @@ pub fn FileRow(file: KnowledgeFile, on_delete: EventHandler<String>) -> Element
let id = file.id.clone();
move |_| on_delete.call(id.clone())
},
"Delete"
"{t(l, \"common.delete\")}"
}
}
}

View File

@@ -1,6 +1,8 @@
use crate::Route;
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
use crate::Route;
/// Login redirect component.
///
/// Redirects the user to the external OAuth authentication endpoint.
@@ -12,6 +14,8 @@ use dioxus::prelude::*;
#[component]
pub fn Login(redirect_url: String) -> Element {
let navigator = use_navigator();
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
use_effect(move || {
// Default to /dashboard when redirect_url is empty.
@@ -25,6 +29,6 @@ pub fn Login(redirect_url: String) -> Element {
});
rsx!(
div { class: "text-center p-6", "Redirecting to secure login page…" }
div { class: "text-center p-6", "{t(l, \"auth.redirecting_secure\")}" }
)
}

View File

@@ -1,6 +1,8 @@
use crate::models::PricingPlan;
use dioxus::prelude::*;
use crate::i18n::{t, tw, Locale};
use crate::models::PricingPlan;
/// Renders a pricing plan card with features list and call-to-action button.
///
/// # Arguments
@@ -9,6 +11,9 @@ use dioxus::prelude::*;
/// * `on_select` - Callback fired when the CTA button is clicked
#[component]
pub fn PricingCard(plan: PricingPlan, on_select: EventHandler<String>) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let card_class = if plan.highlighted {
"pricing-card pricing-card--highlighted"
} else {
@@ -16,8 +21,8 @@ pub fn PricingCard(plan: PricingPlan, on_select: EventHandler<String>) -> Elemen
};
let seats_label = match plan.max_seats {
Some(n) => format!("Up to {n} seats"),
None => "Unlimited seats".to_string(),
Some(n) => tw(l, "common.up_to_seats", &[("n", &n.to_string())]),
None => t(l, "common.unlimited_seats"),
};
rsx! {
@@ -25,7 +30,7 @@ pub fn PricingCard(plan: PricingPlan, on_select: EventHandler<String>) -> Elemen
h3 { class: "pricing-card-name", "{plan.name}" }
div { class: "pricing-card-price",
span { class: "pricing-card-amount", "{plan.price_eur}" }
span { class: "pricing-card-period", " EUR / month" }
span { class: "pricing-card-period", " {t(l, \"common.eur_per_month\")}" }
}
p { class: "pricing-card-seats", "{seats_label}" }
ul { class: "pricing-card-features",
@@ -39,7 +44,7 @@ pub fn PricingCard(plan: PricingPlan, on_select: EventHandler<String>) -> Elemen
let id = plan.id.clone();
move |_| on_select.call(id.clone())
},
"Get Started"
"{t(l, \"common.get_started\")}"
}
}
}

View File

@@ -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 &current_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}" }
}

View File

@@ -9,7 +9,7 @@ use dioxus::prelude::*;
/// * `route` - Route to navigate to when clicked
#[derive(Clone, PartialEq)]
pub struct SubNavItem {
pub label: &'static str,
pub label: String,
pub route: Route,
}

View File

@@ -1,6 +1,8 @@
use crate::models::McpTool;
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
use crate::models::McpTool;
/// Renders an MCP tool card with name, description, status indicator, and toggle.
///
/// # Arguments
@@ -9,6 +11,9 @@ use dioxus::prelude::*;
/// * `on_toggle` - Callback fired when the enable/disable toggle is clicked
#[component]
pub fn ToolCard(tool: McpTool, on_toggle: EventHandler<String>) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let status_class = format!("tool-status tool-status--{}", tool.status.css_class());
let toggle_class = if tool.enabled {
"tool-toggle tool-toggle--on"
@@ -33,9 +38,9 @@ pub fn ToolCard(tool: McpTool, on_toggle: EventHandler<String>) -> Element {
move |_| on_toggle.call(id.clone())
},
if tool.enabled {
"ON"
"{t(l, \"common.on\")}"
} else {
"OFF"
"{t(l, \"common.off\")}"
}
}
}