feat(i18n): add internationalization with DE, FR, ES, PT translations (#12)
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
+12 -7
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\")}"
}
}
}