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,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")} }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\")}" }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}" }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user