feat(i18n): add internationalization with DE, FR, ES, PT translations
All checks were successful
CI / Format (push) Successful in 30s
CI / Clippy (push) Successful in 2m36s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Successful in 3s
CI / Clippy (pull_request) Successful in 2m15s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Deploy (push) Has been skipped
CI / Deploy (pull_request) Has been skipped

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>
This commit is contained in:
Sharang Parnerkar
2026-02-22 12:39:17 +01:00
parent 50237f5377
commit 007c7e2686
39 changed files with 2536 additions and 374 deletions

View File

@@ -1,3 +1,4 @@
use crate::i18n::Locale;
use crate::{components::*, pages::*};
use dioxus::prelude::*;
@@ -61,8 +62,29 @@ const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
display=swap";
/// Root application component. Loads global assets and mounts the router.
///
/// Provides a `Signal<Locale>` context that all child components can read
/// via `use_context::<Signal<Locale>>()` to access the current locale.
/// The locale is persisted in `localStorage` under `"certifai_locale"`.
#[component]
pub fn App() -> Element {
// Read persisted locale from localStorage on first render.
let initial_locale = {
#[cfg(feature = "web")]
{
web_sys::window()
.and_then(|w| w.local_storage().ok().flatten())
.and_then(|s| s.get_item("certifai_locale").ok().flatten())
.map(|code| Locale::from_code(&code))
.unwrap_or_default()
}
#[cfg(not(feature = "web"))]
{
Locale::default()
}
};
use_context_provider(|| Signal::new(initial_locale));
rsx! {
// Seggwat feedback widget
document::Script {

View File

@@ -1,6 +1,7 @@
use dioxus::prelude::*;
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 +13,8 @@ use crate::Route;
/// sidebar with real user data and the active child route.
#[component]
pub fn AppShell() -> Element {
let locale = use_context::<Signal<Locale>>();
// use_resource memoises the async call and avoids infinite re-render
// loops that use_effect + spawn + signal writes can cause.
#[allow(clippy::redundant_closure)]
@@ -40,16 +43,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 +61,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,
@@ -24,39 +29,49 @@ struct NavItem {
/// * `avatar_url` - URL for the avatar image (unused placeholder for now).
#[component]
pub fn Sidebar(name: String, email: String, avatar_url: String) -> 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 +79,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 }
div { class: "sidebar-top-row",
SidebarHeader { name, email: email.clone(), avatar_url }
LocalePicker {}
}
nav { class: "sidebar-nav",
for item in nav_items {
@@ -76,10 +95,10 @@ 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,
};
@@ -99,7 +118,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 +176,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 +236,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 +257,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\")}"
}
}
}

242
src/i18n/mod.rs Normal file
View File

@@ -0,0 +1,242 @@
use std::collections::HashMap;
use std::sync::LazyLock;
use serde_json::Value;
/// Supported application locales.
///
/// Each variant maps to an ISO 639-1 code and a human-readable label
/// displayed in the language picker.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Locale {
#[default]
En,
De,
Fr,
Es,
Pt,
}
impl Locale {
/// ISO 639-1 language code.
pub fn code(self) -> &'static str {
match self {
Locale::En => "en",
Locale::De => "de",
Locale::Fr => "fr",
Locale::Es => "es",
Locale::Pt => "pt",
}
}
/// Human-readable label in the locale's own language.
pub fn label(self) -> &'static str {
match self {
Locale::En => "English",
Locale::De => "Deutsch",
Locale::Fr => "Francais",
Locale::Es => "Espanol",
Locale::Pt => "Portugues",
}
}
/// All available locales.
pub fn all() -> &'static [Locale] {
&[Locale::En, Locale::De, Locale::Fr, Locale::Es, Locale::Pt]
}
/// Parse a locale from its ISO 639-1 code.
///
/// Returns `Locale::En` for unrecognized codes.
pub fn from_code(code: &str) -> Self {
match code {
"de" => Locale::De,
"fr" => Locale::Fr,
"es" => Locale::Es,
"pt" => Locale::Pt,
_ => Locale::En,
}
}
}
type TranslationMap = HashMap<String, String>;
/// All translations loaded at compile time and parsed lazily on first access.
///
/// Uses `LazyLock` (stable since Rust 1.80) to avoid runtime file I/O.
/// Each locale's JSON is embedded via `include_str!` and flattened into
/// dot-separated keys (e.g. `"nav.dashboard"` -> `"Dashboard"`).
static TRANSLATIONS: LazyLock<HashMap<&'static str, TranslationMap>> = LazyLock::new(|| {
let mut map = HashMap::with_capacity(5);
map.insert(
"en",
parse_translations(include_str!("../../assets/i18n/en.json")),
);
map.insert(
"de",
parse_translations(include_str!("../../assets/i18n/de.json")),
);
map.insert(
"fr",
parse_translations(include_str!("../../assets/i18n/fr.json")),
);
map.insert(
"es",
parse_translations(include_str!("../../assets/i18n/es.json")),
);
map.insert(
"pt",
parse_translations(include_str!("../../assets/i18n/pt.json")),
);
map
});
/// Parse a JSON string into a flat `key -> value` map.
///
/// Nested objects are flattened with dot separators:
/// `{ "nav": { "home": "Home" } }` becomes `"nav.home" -> "Home"`.
fn parse_translations(json: &str) -> TranslationMap {
// SAFETY: translation JSON files are bundled at compile time and are
// validated during development. A malformed file will panic here during
// the first access, which surfaces immediately in testing.
let value: Value = serde_json::from_str(json).unwrap_or(Value::Object(Default::default()));
let mut map = TranslationMap::new();
flatten_json("", &value, &mut map);
map
}
/// Recursively flatten a JSON value into dot-separated keys.
fn flatten_json(prefix: &str, value: &Value, map: &mut TranslationMap) {
match value {
Value::Object(obj) => {
for (key, val) in obj {
let new_prefix = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
flatten_json(&new_prefix, val, map);
}
}
Value::String(s) => {
map.insert(prefix.to_string(), s.clone());
}
// Non-string leaf values are skipped (numbers, bools, nulls)
_ => {}
}
}
/// Look up a translation for the given locale and key.
///
/// Falls back to English if the key is missing in the target locale.
/// Returns the raw key if not found in any locale (useful for debugging
/// missing translations).
///
/// # Arguments
///
/// * `locale` - The target locale
/// * `key` - Dot-separated translation key (e.g. `"nav.dashboard"`)
///
/// # Returns
///
/// The translated string, or the key itself as a fallback.
pub fn t(locale: Locale, key: &str) -> String {
TRANSLATIONS
.get(locale.code())
.and_then(|map| map.get(key))
.cloned()
.unwrap_or_else(|| {
// Fallback to English
TRANSLATIONS
.get("en")
.and_then(|map| map.get(key))
.cloned()
.unwrap_or_else(|| key.to_string())
})
}
/// Look up a translation and substitute variables.
///
/// Variables in the translation string use `{name}` syntax.
/// Each `(name, value)` pair in `vars` replaces `{name}` with `value`.
///
/// # Arguments
///
/// * `locale` - The target locale
/// * `key` - Dot-separated translation key
/// * `vars` - Slice of `(name, value)` pairs for substitution
///
/// # Returns
///
/// The translated string with all variables substituted.
///
/// # Examples
///
/// ```
/// use dashboard::i18n::{tw, Locale};
/// let text = tw(Locale::En, "chat.minutes_ago", &[("n", "5")]);
/// assert_eq!(text, "5m ago");
/// ```
pub fn tw(locale: Locale, key: &str, vars: &[(&str, &str)]) -> String {
let mut result = t(locale, key);
for (name, value) in vars {
result = result.replace(&format!("{{{name}}}"), value);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn english_lookup() {
let result = t(Locale::En, "nav.dashboard");
assert_eq!(result, "Dashboard");
}
#[test]
fn german_lookup() {
let result = t(Locale::De, "nav.dashboard");
assert_eq!(result, "Dashboard");
}
#[test]
fn fallback_to_english() {
// If a key exists in English but not in another locale, English is returned
let en = t(Locale::En, "common.loading");
let result = t(Locale::De, "common.loading");
// German should have its own translation, but if missing, falls back to EN
assert!(!result.is_empty());
// Just verify it doesn't return the key itself
assert_ne!(result, "common.loading");
let _ = en; // suppress unused warning
}
#[test]
fn missing_key_returns_key() {
let result = t(Locale::En, "nonexistent.key");
assert_eq!(result, "nonexistent.key");
}
#[test]
fn variable_substitution() {
let result = tw(Locale::En, "chat.minutes_ago", &[("n", "5")]);
assert_eq!(result, "5m ago");
}
#[test]
fn locale_from_code() {
assert_eq!(Locale::from_code("de"), Locale::De);
assert_eq!(Locale::from_code("fr"), Locale::Fr);
assert_eq!(Locale::from_code("unknown"), Locale::En);
}
#[test]
fn all_locales_loaded() {
for locale in Locale::all() {
let result = t(*locale, "nav.dashboard");
assert!(!result.is_empty());
}
}
}

View File

@@ -1,11 +1,12 @@
mod app;
mod components;
pub mod i18n;
pub mod infrastructure;
mod models;
mod pages;
pub use app::*;
pub use components::*;
pub use i18n::*;
pub use models::*;
pub use pages::*;

View File

@@ -1,6 +1,7 @@
use crate::components::{
ChatActionBar, ChatInputBar, ChatMessageList, ChatModelSelector, ChatSidebar,
};
use crate::i18n::{t, Locale};
use crate::infrastructure::chat::{
chat_complete, create_chat_session, delete_chat_session, list_chat_messages,
list_chat_sessions, rename_chat_session, save_chat_message,
@@ -15,6 +16,9 @@ use dioxus::prelude::*;
/// Messages stream via `EventSource` connected to `/api/chat/stream`.
#[component]
pub fn ChatPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// ---- Signals ----
let mut active_session_id: Signal<Option<String>> = use_signal(|| None);
let mut messages: Signal<Vec<ChatMessage>> = use_signal(Vec::new);
@@ -68,9 +72,10 @@ pub fn ChatPage() -> Element {
// Create new session
let on_new = move |_: ()| {
let model = selected_model.read().clone();
let new_chat_title = t(l, "chat.new_chat");
spawn(async move {
match create_chat_session(
"New Chat".to_string(),
new_chat_title,
"General".to_string(),
"ollama".to_string(),
model,
@@ -235,14 +240,17 @@ pub fn ChatPage() -> Element {
let on_share = move |_: ()| {
#[cfg(feature = "web")]
{
let you_label = t(l, "chat.you");
let assistant_label = t(l, "chat.assistant");
let text: String = messages
.read()
.iter()
.filter(|m| m.role != ChatRole::System)
.map(|m| {
let label = match m.role {
ChatRole::User => "You",
ChatRole::Assistant => "Assistant",
ChatRole::User => &you_label,
ChatRole::Assistant => &assistant_label,
// Filtered out above, but required for exhaustive match
ChatRole::System => "System",
};
format!("{label}:\n{}\n", m.content)

View File

@@ -2,6 +2,7 @@ use dioxus::prelude::*;
use dioxus_sdk::storage::use_persistent;
use crate::components::{ArticleDetail, DashboardSidebar, NewsCardView, PageHeader};
use crate::i18n::{t, Locale};
use crate::infrastructure::chat::{create_chat_session, save_chat_message};
use crate::infrastructure::llm::FollowUpMessage;
use crate::models::NewsCard;
@@ -28,6 +29,9 @@ const DEFAULT_TOPICS: &[&str] = &[
/// - `certifai_ollama_model`: Ollama model ID for summarization
#[component]
pub fn DashboardPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// Persistent state stored in localStorage
let mut custom_topics = use_persistent("certifai_topics".to_string(), Vec::<String>::new);
// Default to empty so the server functions use OLLAMA_URL / OLLAMA_MODEL
@@ -133,8 +137,8 @@ pub fn DashboardPage() -> Element {
rsx! {
section { class: "dashboard-page",
PageHeader {
title: "Dashboard".to_string(),
subtitle: "AI news and updates".to_string(),
title: t(l, "dashboard.title"),
subtitle: t(l, "dashboard.subtitle"),
}
// Topic tabs row
@@ -188,7 +192,7 @@ pub fn DashboardPage() -> Element {
input {
class: "topic-input",
r#type: "text",
placeholder: "Topic name...",
placeholder: "{t(l, \"dashboard.topic_placeholder\")}",
value: "{new_topic_text}",
oninput: move |e| new_topic_text.set(e.value()),
onkeypress: move |e| {
@@ -214,7 +218,7 @@ pub fn DashboardPage() -> Element {
show_add_input.set(false);
new_topic_text.set(String::new());
},
"Cancel"
"{t(l, \"common.cancel\")}"
}
}
} else {
@@ -236,33 +240,33 @@ pub fn DashboardPage() -> Element {
}
show_settings.set(!currently_shown);
},
"Settings"
"{t(l, \"common.settings\")}"
}
}
// Settings panel (collapsible)
if *show_settings.read() {
div { class: "settings-panel",
h4 { class: "settings-panel-title", "Ollama Settings" }
h4 { class: "settings-panel-title", "{t(l, \"dashboard.ollama_settings\")}" }
p { class: "settings-hint",
"Leave empty to use OLLAMA_URL / OLLAMA_MODEL from .env"
"{t(l, \"dashboard.settings_hint\")}"
}
div { class: "settings-field",
label { "Ollama URL" }
label { "{t(l, \"dashboard.ollama_url\")}" }
input {
class: "settings-input",
r#type: "text",
placeholder: "Uses OLLAMA_URL from .env",
placeholder: "{t(l, \"dashboard.ollama_url_placeholder\")}",
value: "{settings_url}",
oninput: move |e| settings_url.set(e.value()),
}
}
div { class: "settings-field",
label { "Model" }
label { "{t(l, \"dashboard.model\")}" }
input {
class: "settings-input",
r#type: "text",
placeholder: "Uses OLLAMA_MODEL from .env",
placeholder: "{t(l, \"dashboard.model_placeholder\")}",
value: "{settings_model}",
oninput: move |e| settings_model.set(e.value()),
}
@@ -274,14 +278,14 @@ pub fn DashboardPage() -> Element {
*ollama_model.write() = settings_model.read().trim().to_string();
show_settings.set(false);
},
"Save"
"{t(l, \"common.save\")}"
}
}
}
// Loading / error state
if is_loading {
div { class: "dashboard-loading", "Searching..." }
div { class: "dashboard-loading", "{t(l, \"dashboard.searching\")}" }
}
if let Some(ref err) = search_error {
div { class: "settings-hint", "{err}" }

View File

@@ -1,23 +1,26 @@
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
/// Agents page placeholder for the LangGraph agent builder.
///
/// Shows a "Coming Soon" card with a disabled launch button.
/// Will eventually integrate with the LangGraph framework.
#[component]
pub fn AgentsPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
section { class: "placeholder-page",
div { class: "placeholder-card",
div { class: "placeholder-icon", "A" }
h2 { "Agent Builder" }
h2 { "{t(l, \"developer.agents_title\")}" }
p { class: "placeholder-desc",
"Build and manage AI agents with LangGraph. \
Create multi-step reasoning pipelines, tool-using agents, \
and autonomous workflows."
"{t(l, \"developer.agents_desc\")}"
}
button { class: "btn-primary", disabled: true, "Launch Agent Builder" }
span { class: "placeholder-badge", "Coming Soon" }
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_agents\")}" }
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
}
}
}

View File

@@ -1,5 +1,6 @@
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
use crate::models::AnalyticsMetric;
/// Analytics page placeholder for LangFuse integration.
@@ -8,7 +9,10 @@ use crate::models::AnalyticsMetric;
/// plus a mock stats bar showing sample metrics.
#[component]
pub fn AnalyticsPage() -> Element {
let metrics = mock_metrics();
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let metrics = mock_metrics(l);
rsx! {
section { class: "placeholder-page",
@@ -25,39 +29,41 @@ pub fn AnalyticsPage() -> Element {
}
div { class: "placeholder-card",
div { class: "placeholder-icon", "L" }
h2 { "Analytics & Observability" }
h2 { "{t(l, \"developer.analytics_title\")}" }
p { class: "placeholder-desc",
"Monitor and analyze your AI pipelines with LangFuse. \
Track token usage, latency, costs, and quality metrics \
across all your deployments."
"{t(l, \"developer.analytics_desc\")}"
}
button { class: "btn-primary", disabled: true, "Launch LangFuse" }
span { class: "placeholder-badge", "Coming Soon" }
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_analytics\")}" }
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
}
}
}
}
/// Returns mock analytics metrics for the stats bar.
fn mock_metrics() -> Vec<AnalyticsMetric> {
///
/// # Arguments
///
/// * `locale` - The current locale for translating metric labels
fn mock_metrics(locale: Locale) -> Vec<AnalyticsMetric> {
vec![
AnalyticsMetric {
label: "Total Requests".into(),
label: t(locale, "developer.total_requests"),
value: "12,847".into(),
change_pct: 14.2,
},
AnalyticsMetric {
label: "Avg Latency".into(),
label: t(locale, "developer.avg_latency"),
value: "245ms".into(),
change_pct: -8.5,
},
AnalyticsMetric {
label: "Tokens Used".into(),
label: t(locale, "developer.tokens_used"),
value: "2.4M".into(),
change_pct: 22.1,
},
AnalyticsMetric {
label: "Error Rate".into(),
label: t(locale, "developer.error_rate"),
value: "0.3%".into(),
change_pct: -12.0,
},

View File

@@ -1,23 +1,26 @@
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
/// Flow page placeholder for the LangFlow visual workflow builder.
///
/// Shows a "Coming Soon" card with a disabled launch button.
/// Will eventually integrate with LangFlow for visual flow design.
#[component]
pub fn FlowPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
section { class: "placeholder-page",
div { class: "placeholder-card",
div { class: "placeholder-icon", "F" }
h2 { "Flow Builder" }
h2 { "{t(l, \"developer.flow_title\")}" }
p { class: "placeholder-desc",
"Design visual AI workflows with LangFlow. \
Drag-and-drop nodes to create data processing pipelines, \
prompt chains, and integration flows."
"{t(l, \"developer.flow_desc\")}"
}
button { class: "btn-primary", disabled: true, "Launch Flow Builder" }
span { class: "placeholder-badge", "Coming Soon" }
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_flow\")}" }
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
}
}
}

View File

@@ -10,6 +10,7 @@ use dioxus::prelude::*;
use crate::app::Route;
use crate::components::sub_nav::{SubNav, SubNavItem};
use crate::i18n::{t, Locale};
/// Shell layout for the Developer section.
///
@@ -17,17 +18,20 @@ use crate::components::sub_nav::{SubNav, SubNavItem};
/// the child route outlet. Sits inside the main `AppShell` layout.
#[component]
pub fn DeveloperShell() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let tabs = vec![
SubNavItem {
label: "Agents",
label: t(l, "nav.agents"),
route: Route::AgentsPage {},
},
SubNavItem {
label: "Flow",
label: t(l, "nav.flow"),
route: Route::FlowPage {},
},
SubNavItem {
label: "Analytics",
label: t(l, "nav.analytics"),
route: Route::AnalyticsPage {},
},
];

View File

@@ -2,6 +2,7 @@ use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
use dioxus_free_icons::Icon;
use crate::i18n::{t, Locale};
use crate::Route;
/// Impressum (legal notice) page required by German/EU law.
@@ -10,6 +11,9 @@ use crate::Route;
/// accessible without authentication.
#[component]
pub fn ImpressumPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
div { class: "legal-page",
nav { class: "legal-nav",
@@ -21,53 +25,53 @@ pub fn ImpressumPage() -> Element {
}
}
main { class: "legal-content",
h1 { "Impressum" }
h1 { "{t(l, \"impressum.title\")}" }
h2 { "Information according to 5 TMG" }
h2 { "{t(l, \"impressum.info_tmg\")}" }
p {
"CERTifAI GmbH"
"{t(l, \"impressum.company\")}"
br {}
"Musterstrasse 1"
"{t(l, \"impressum.address_street\")}"
br {}
"10115 Berlin"
"{t(l, \"impressum.address_city\")}"
br {}
"Germany"
"{t(l, \"impressum.address_country\")}"
}
h2 { "Represented by" }
p { "Managing Director: [Name]" }
h2 { "{t(l, \"impressum.represented_by\")}" }
p { "{t(l, \"impressum.managing_director\")}" }
h2 { "Contact" }
h2 { "{t(l, \"impressum.contact\")}" }
p {
"Email: info@certifai.example"
"{t(l, \"impressum.email\")}"
br {}
"Phone: +49 (0) 30 1234567"
"{t(l, \"impressum.phone\")}"
}
h2 { "Commercial Register" }
h2 { "{t(l, \"impressum.commercial_register\")}" }
p {
"Registered at: Amtsgericht Berlin-Charlottenburg"
"{t(l, \"impressum.registered_at\")}"
br {}
"Registration number: HRB XXXXXX"
"{t(l, \"impressum.registration_number\")}"
}
h2 { "VAT ID" }
p { "VAT identification number according to 27a UStG: DE XXXXXXXXX" }
h2 { "{t(l, \"impressum.vat_id\")}" }
p { "{t(l, \"impressum.vat_number\")}" }
h2 { "Responsible for content according to 55 Abs. 2 RStV" }
h2 { "{t(l, \"impressum.responsible_content\")}" }
p {
"[Name]"
br {}
"CERTifAI GmbH"
"{t(l, \"impressum.company\")}"
br {}
"Musterstrasse 1"
"{t(l, \"impressum.address_street\")}"
br {}
"10115 Berlin"
"{t(l, \"impressum.address_city\")}"
}
}
footer { class: "legal-footer",
Link { to: Route::LandingPage {}, "Back to Home" }
Link { to: Route::PrivacyPage {}, "Privacy Policy" }
Link { to: Route::LandingPage {}, "{t(l, \"common.back_to_home\")}" }
Link { to: Route::PrivacyPage {}, "{t(l, \"common.privacy_policy\")}" }
}
}
}

View File

@@ -1,6 +1,7 @@
use dioxus::prelude::*;
use crate::components::{FileRow, PageHeader};
use crate::i18n::{t, Locale};
use crate::models::{FileKind, KnowledgeFile};
/// Knowledge Base page with file explorer table and upload controls.
@@ -9,6 +10,9 @@ use crate::models::{FileKind, KnowledgeFile};
/// metadata, chunk counts, and management actions.
#[component]
pub fn KnowledgePage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let mut files = use_signal(mock_files);
let mut search_query = use_signal(String::new);
@@ -29,17 +33,17 @@ pub fn KnowledgePage() -> Element {
rsx! {
section { class: "knowledge-page",
PageHeader {
title: "Knowledge Base".to_string(),
subtitle: "Manage documents for RAG retrieval".to_string(),
title: t(l, "knowledge.title"),
subtitle: t(l, "knowledge.subtitle"),
actions: rsx! {
button { class: "btn-primary", "Upload File" }
button { class: "btn-primary", {t(l, "common.upload_file")} }
},
}
div { class: "knowledge-toolbar",
input {
class: "form-input knowledge-search",
r#type: "text",
placeholder: "Search files...",
placeholder: t(l, "knowledge.search_placeholder"),
value: "{search_query}",
oninput: move |evt: Event<FormData>| {
search_query.set(evt.value());
@@ -50,12 +54,12 @@ pub fn KnowledgePage() -> Element {
table { class: "knowledge-table",
thead {
tr {
th { "Name" }
th { "Type" }
th { "Size" }
th { "Chunks" }
th { "Uploaded" }
th { "Actions" }
th { {t(l, "knowledge.name")} }
th { {t(l, "knowledge.type")} }
th { {t(l, "knowledge.size")} }
th { {t(l, "knowledge.chunks")} }
th { {t(l, "knowledge.uploaded")} }
th { {t(l, "knowledge.actions")} }
}
}
tbody {

View File

@@ -5,6 +5,7 @@ use dioxus_free_icons::icons::bs_icons::{
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
use dioxus_free_icons::Icon;
use crate::i18n::{t, Locale};
use crate::Route;
/// Public landing page for the CERTifAI platform.
@@ -30,6 +31,9 @@ pub fn LandingPage() -> Element {
/// Sticky top navigation bar with logo, nav links, and CTA buttons.
#[component]
fn LandingNav() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
nav { class: "landing-nav",
div { class: "landing-nav-inner",
@@ -40,9 +44,9 @@ fn LandingNav() -> Element {
span { "CERTifAI" }
}
div { class: "landing-nav-links",
a { href: "#features", "Features" }
a { href: "#how-it-works", "How It Works" }
a { href: "#pricing", "Pricing" }
a { href: "#features", {t(l, "common.features")} }
a { href: "#how-it-works", {t(l, "common.how_it_works")} }
a { href: "#pricing", {t(l, "nav.pricing")} }
}
div { class: "landing-nav-actions",
Link {
@@ -50,14 +54,14 @@ fn LandingNav() -> Element {
redirect_url: "/dashboard".into(),
},
class: "btn btn-ghost btn-sm",
"Log In"
{t(l, "common.log_in")}
}
Link {
to: Route::Login {
redirect_url: "/dashboard".into(),
},
class: "btn btn-primary btn-sm",
"Get Started"
{t(l, "common.get_started")}
}
}
}
@@ -68,19 +72,20 @@ fn LandingNav() -> Element {
/// Hero section with headline, subtitle, and CTA buttons.
#[component]
fn HeroSection() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
section { class: "hero-section",
div { class: "hero-content",
div { class: "hero-badge badge badge-outline", "Privacy-First GenAI Infrastructure" }
div { class: "hero-badge badge badge-outline", {t(l, "landing.badge")} }
h1 { class: "hero-title",
"Your AI. Your Data."
{t(l, "landing.hero_title_1")}
br {}
span { class: "hero-title-accent", "Your Infrastructure." }
span { class: "hero-title-accent", {t(l, "landing.hero_title_2")} }
}
p { class: "hero-subtitle",
"Self-hosted, GDPR-compliant generative AI platform for "
"enterprises that refuse to compromise on data sovereignty. "
"Deploy LLMs, agents, and MCP servers on your own terms."
{t(l, "landing.hero_subtitle")}
}
div { class: "hero-actions",
Link {
@@ -88,10 +93,12 @@ fn HeroSection() -> Element {
redirect_url: "/dashboard".into(),
},
class: "btn btn-primary btn-lg",
"Get Started"
{t(l, "common.get_started")}
Icon { icon: BsArrowRight, width: 18, height: 18 }
}
a { href: "#features", class: "btn btn-outline btn-lg", "Learn More" }
a { href: "#features", class: "btn btn-outline btn-lg",
{t(l, "landing.learn_more")}
}
}
}
div { class: "hero-graphic",
@@ -273,31 +280,34 @@ fn HeroSection() -> Element {
/// Social proof / trust indicator strip.
#[component]
fn SocialProof() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
section { class: "social-proof",
p { class: "social-proof-text",
"Built for enterprises that value "
span { class: "social-proof-highlight", "data sovereignty" }
{t(l, "landing.social_proof")}
span { class: "social-proof-highlight", {t(l, "landing.data_sovereignty")} }
}
div { class: "social-proof-stats",
div { class: "proof-stat",
span { class: "proof-stat-value", "100%" }
span { class: "proof-stat-label", "On-Premise" }
span { class: "proof-stat-label", {t(l, "landing.on_premise")} }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "GDPR" }
span { class: "proof-stat-label", "Compliant" }
span { class: "proof-stat-label", {t(l, "landing.compliant")} }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "EU" }
span { class: "proof-stat-label", "Data Residency" }
span { class: "proof-stat-label", {t(l, "landing.data_residency")} }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "Zero" }
span { class: "proof-stat-label", "Third-Party Sharing" }
span { class: "proof-stat-label", {t(l, "landing.third_party")} }
}
}
}
@@ -307,60 +317,57 @@ fn SocialProof() -> Element {
/// Feature cards grid section.
#[component]
fn FeaturesGrid() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
section { id: "features", class: "features-section",
h2 { class: "section-title", "Everything You Need" }
h2 { class: "section-title", {t(l, "landing.features_title")} }
p { class: "section-subtitle",
"A complete, self-hosted GenAI stack under your full control."
{t(l, "landing.features_subtitle")}
}
div { class: "features-grid",
FeatureCard {
icon: rsx! {
Icon { icon: BsServer, width: 28, height: 28 }
},
title: "Self-Hosted Infrastructure",
description: "Deploy on your own hardware or private cloud. \
Full control over your AI stack with no external dependencies.",
title: t(l, "landing.feat_infra_title"),
description: t(l, "landing.feat_infra_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: BsShieldCheck, width: 28, height: 28 }
},
title: "GDPR Compliant",
description: "EU data residency guaranteed. Your data never \
leaves your infrastructure or gets shared with third parties.",
title: t(l, "landing.feat_gdpr_title"),
description: t(l, "landing.feat_gdpr_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: FaCubes, width: 28, height: 28 }
},
title: "LLM Management",
description: "Deploy, monitor, and manage multiple language \
models. Switch between models with zero downtime.",
title: t(l, "landing.feat_llm_title"),
description: t(l, "landing.feat_llm_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: BsRobot, width: 28, height: 28 }
},
title: "Agent Builder",
description: "Create custom AI agents with integrated Langchain \
and Langfuse for full observability and control.",
title: t(l, "landing.feat_agent_title"),
description: t(l, "landing.feat_agent_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: BsGlobe2, width: 28, height: 28 }
},
title: "MCP Server Management",
description: "Manage Model Context Protocol servers to extend \
your AI capabilities with external tool integrations.",
title: t(l, "landing.feat_mcp_title"),
description: t(l, "landing.feat_mcp_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: BsKey, width: 28, height: 28 }
},
title: "API Key Management",
description: "Generate API keys, track usage per seat, and \
set fine-grained permissions for every integration.",
title: t(l, "landing.feat_api_title"),
description: t(l, "landing.feat_api_desc"),
}
}
}
@@ -372,10 +379,10 @@ fn FeaturesGrid() -> Element {
/// # Arguments
///
/// * `icon` - The icon element to display
/// * `title` - Feature title
/// * `description` - Feature description text
/// * `title` - Feature title (owned String from translation lookup)
/// * `description` - Feature description text (owned String from translation lookup)
#[component]
fn FeatureCard(icon: Element, title: &'static str, description: &'static str) -> Element {
fn FeatureCard(icon: Element, title: String, description: String) -> Element {
rsx! {
div { class: "card feature-card",
div { class: "feature-card-icon", {icon} }
@@ -388,31 +395,28 @@ fn FeatureCard(icon: Element, title: &'static str, description: &'static str) ->
/// Three-step "How It Works" section.
#[component]
fn HowItWorks() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
section { id: "how-it-works", class: "how-it-works-section",
h2 { class: "section-title", "Up and Running in Minutes" }
p { class: "section-subtitle", "Three steps to sovereign AI infrastructure." }
h2 { class: "section-title", {t(l, "landing.how_title")} }
p { class: "section-subtitle", {t(l, "landing.how_subtitle")} }
div { class: "steps-grid",
StepCard {
number: "01",
title: "Deploy",
description: "Install CERTifAI on your infrastructure \
with a single command. Supports Docker, Kubernetes, \
and bare metal.",
title: t(l, "landing.step_deploy"),
description: t(l, "landing.step_deploy_desc"),
}
StepCard {
number: "02",
title: "Configure",
description: "Connect your identity provider, select \
your models, and set up team permissions through \
the admin dashboard.",
title: t(l, "landing.step_configure"),
description: t(l, "landing.step_configure_desc"),
}
StepCard {
number: "03",
title: "Scale",
description: "Add users, deploy more models, and \
integrate with your existing tools via API keys \
and MCP servers.",
title: t(l, "landing.step_scale"),
description: t(l, "landing.step_scale_desc"),
}
}
}
@@ -424,10 +428,10 @@ fn HowItWorks() -> Element {
/// # Arguments
///
/// * `number` - Step number string (e.g. "01")
/// * `title` - Step title
/// * `description` - Step description text
/// * `title` - Step title (owned String from translation lookup)
/// * `description` - Step description text (owned String from translation lookup)
#[component]
fn StepCard(number: &'static str, title: &'static str, description: &'static str) -> Element {
fn StepCard(number: &'static str, title: String, description: String) -> Element {
rsx! {
div { class: "step-card",
span { class: "step-number", "{number}" }
@@ -440,11 +444,14 @@ fn StepCard(number: &'static str, title: &'static str, description: &'static str
/// Call-to-action banner before the footer.
#[component]
fn CtaBanner() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
section { class: "cta-banner",
h2 { class: "cta-title", "Ready to take control of your AI infrastructure?" }
h2 { class: "cta-title", {t(l, "landing.cta_title")} }
p { class: "cta-subtitle",
"Start deploying sovereign GenAI today. No credit card required."
{t(l, "landing.cta_subtitle")}
}
div { class: "cta-actions",
Link {
@@ -452,7 +459,7 @@ fn CtaBanner() -> Element {
redirect_url: "/dashboard".into(),
},
class: "btn btn-primary btn-lg",
"Get Started Free"
{t(l, "landing.get_started_free")}
Icon { icon: BsArrowRight, width: 18, height: 18 }
}
Link {
@@ -460,7 +467,7 @@ fn CtaBanner() -> Element {
redirect_url: "/dashboard".into(),
},
class: "btn btn-outline btn-lg",
"Log In"
{t(l, "common.log_in")}
}
}
}
@@ -470,6 +477,9 @@ fn CtaBanner() -> Element {
/// Landing page footer with links and copyright.
#[component]
fn LandingFooter() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
footer { class: "landing-footer",
div { class: "landing-footer-inner",
@@ -480,28 +490,28 @@ fn LandingFooter() -> Element {
}
span { "CERTifAI" }
}
p { class: "footer-tagline", "Sovereign GenAI infrastructure for enterprises." }
p { class: "footer-tagline", {t(l, "landing.footer_tagline")} }
}
div { class: "footer-links-group",
h4 { class: "footer-links-heading", "Product" }
a { href: "#features", "Features" }
a { href: "#how-it-works", "How It Works" }
a { href: "#pricing", "Pricing" }
h4 { class: "footer-links-heading", {t(l, "landing.product")} }
a { href: "#features", {t(l, "common.features")} }
a { href: "#how-it-works", {t(l, "common.how_it_works")} }
a { href: "#pricing", {t(l, "nav.pricing")} }
}
div { class: "footer-links-group",
h4 { class: "footer-links-heading", "Legal" }
Link { to: Route::ImpressumPage {}, "Impressum" }
Link { to: Route::PrivacyPage {}, "Privacy Policy" }
h4 { class: "footer-links-heading", {t(l, "landing.legal")} }
Link { to: Route::ImpressumPage {}, {t(l, "common.impressum")} }
Link { to: Route::PrivacyPage {}, {t(l, "common.privacy_policy")} }
}
div { class: "footer-links-group",
h4 { class: "footer-links-heading", "Resources" }
a { href: "#", "Documentation" }
a { href: "#", "API Reference" }
a { href: "#", "Support" }
h4 { class: "footer-links-heading", {t(l, "landing.resources")} }
a { href: "#", {t(l, "landing.documentation")} }
a { href: "#", {t(l, "landing.api_reference")} }
a { href: "#", {t(l, "landing.support")} }
}
}
div { class: "footer-bottom",
p { "2026 CERTifAI. All rights reserved." }
p { {t(l, "landing.copyright")} }
}
}
}

View File

@@ -1,6 +1,7 @@
use dioxus::prelude::*;
use crate::components::{MemberRow, PageHeader};
use crate::i18n::{t, tw, Locale};
use crate::models::{BillingUsage, MemberRole, OrgMember};
/// Organization dashboard with billing stats, member table, and invite modal.
@@ -9,6 +10,9 @@ use crate::models::{BillingUsage, MemberRole, OrgMember};
/// with role management, and a button to invite new members.
#[component]
pub fn OrgDashboardPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let members = use_signal(mock_members);
let usage = mock_usage();
let mut show_invite = use_signal(|| false);
@@ -23,10 +27,10 @@ pub fn OrgDashboardPage() -> Element {
rsx! {
section { class: "org-dashboard-page",
PageHeader {
title: "Organization".to_string(),
subtitle: "Manage members and billing".to_string(),
title: t(l, "org.title"),
subtitle: t(l, "org.subtitle"),
actions: rsx! {
button { class: "btn-primary", onclick: move |_| show_invite.set(true), "Invite Member" }
button { class: "btn-primary", onclick: move |_| show_invite.set(true), {t(l, "org.invite_member")} }
},
}
@@ -34,15 +38,15 @@ pub fn OrgDashboardPage() -> Element {
div { class: "org-stats-bar",
div { class: "org-stat",
span { class: "org-stat-value", "{usage.seats_used}/{usage.seats_total}" }
span { class: "org-stat-label", "Seats Used" }
span { class: "org-stat-label", {t(l, "org.seats_used")} }
}
div { class: "org-stat",
span { class: "org-stat-value", "{tokens_display}" }
span { class: "org-stat-label", "of {tokens_limit_display} tokens" }
span { class: "org-stat-label", {tw(l, "org.of_tokens", &[("limit", &tokens_limit_display)])} }
}
div { class: "org-stat",
span { class: "org-stat-value", "{usage.billing_cycle_end}" }
span { class: "org-stat-label", "Cycle Ends" }
span { class: "org-stat-label", {t(l, "org.cycle_ends")} }
}
}
@@ -51,10 +55,10 @@ pub fn OrgDashboardPage() -> Element {
table { class: "org-table",
thead {
tr {
th { "Name" }
th { "Email" }
th { "Role" }
th { "Joined" }
th { {t(l, "org.name")} }
th { {t(l, "org.email")} }
th { {t(l, "org.role")} }
th { {t(l, "org.joined")} }
}
}
tbody {
@@ -78,13 +82,13 @@ pub fn OrgDashboardPage() -> Element {
class: "modal-content",
// Prevent clicks inside modal from closing it
onclick: move |evt: Event<MouseData>| evt.stop_propagation(),
h3 { "Invite New Member" }
h3 { {t(l, "org.invite_title")} }
div { class: "form-group",
label { "Email Address" }
label { {t(l, "org.email_address")} }
input {
class: "form-input",
r#type: "email",
placeholder: "colleague@company.com",
placeholder: t(l, "org.email_placeholder"),
value: "{invite_email}",
oninput: move |evt: Event<FormData>| {
invite_email.set(evt.value());
@@ -95,12 +99,12 @@ pub fn OrgDashboardPage() -> Element {
button {
class: "btn-secondary",
onclick: move |_| show_invite.set(false),
"Cancel"
{t(l, "common.cancel")}
}
button {
class: "btn-primary",
onclick: move |_| show_invite.set(false),
"Send Invite"
{t(l, "org.send_invite")}
}
}
}

View File

@@ -8,6 +8,7 @@ use dioxus::prelude::*;
use crate::app::Route;
use crate::components::sub_nav::{SubNav, SubNavItem};
use crate::i18n::{t, Locale};
/// Shell layout for the Organization section.
///
@@ -15,13 +16,16 @@ use crate::components::sub_nav::{SubNav, SubNavItem};
/// the child route outlet. Sits inside the main `AppShell` layout.
#[component]
pub fn OrgShell() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let tabs = vec![
SubNavItem {
label: "Pricing",
label: t(l, "nav.pricing"),
route: Route::OrgPricingPage {},
},
SubNavItem {
label: "Dashboard",
label: t(l, "nav.dashboard"),
route: Route::OrgDashboardPage {},
},
];

View File

@@ -2,6 +2,7 @@ use dioxus::prelude::*;
use crate::app::Route;
use crate::components::{PageHeader, PricingCard};
use crate::i18n::{t, tw, Locale};
use crate::models::PricingPlan;
/// Organization pricing page displaying three plan tiers.
@@ -10,14 +11,17 @@ use crate::models::PricingPlan;
/// organization dashboard.
#[component]
pub fn OrgPricingPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let navigator = use_navigator();
let plans = mock_plans();
let plans = mock_plans(l);
rsx! {
section { class: "pricing-page",
PageHeader {
title: "Pricing".to_string(),
subtitle: "Choose the plan that fits your organization".to_string(),
title: t(l, "org.pricing_title"),
subtitle: t(l, "org.pricing_subtitle"),
}
div { class: "pricing-grid",
for plan in plans {
@@ -34,52 +38,56 @@ pub fn OrgPricingPage() -> Element {
}
}
/// Returns mock pricing plans.
fn mock_plans() -> Vec<PricingPlan> {
/// Returns mock pricing plans with translated names and features.
///
/// # Arguments
///
/// * `l` - The active locale for translating user-facing plan text
fn mock_plans(l: Locale) -> Vec<PricingPlan> {
vec![
PricingPlan {
id: "starter".into(),
name: "Starter".into(),
name: t(l, "pricing.starter"),
price_eur: 49,
features: vec![
"Up to 5 users".into(),
"1 LLM provider".into(),
"100K tokens/month".into(),
"Community support".into(),
"Basic analytics".into(),
tw(l, "pricing.up_to_users", &[("n", "5")]),
t(l, "pricing.llm_provider_1"),
t(l, "pricing.tokens_100k"),
t(l, "pricing.community_support"),
t(l, "pricing.basic_analytics"),
],
highlighted: false,
max_seats: Some(5),
},
PricingPlan {
id: "team".into(),
name: "Team".into(),
name: t(l, "pricing.team"),
price_eur: 199,
features: vec![
"Up to 25 users".into(),
"All LLM providers".into(),
"1M tokens/month".into(),
"Priority support".into(),
"Advanced analytics".into(),
"Custom MCP tools".into(),
"SSO integration".into(),
tw(l, "pricing.up_to_users", &[("n", "25")]),
t(l, "pricing.all_providers"),
t(l, "pricing.tokens_1m"),
t(l, "pricing.priority_support"),
t(l, "pricing.advanced_analytics"),
t(l, "pricing.custom_mcp"),
t(l, "pricing.sso"),
],
highlighted: true,
max_seats: Some(25),
},
PricingPlan {
id: "enterprise".into(),
name: "Enterprise".into(),
name: t(l, "pricing.enterprise"),
price_eur: 499,
features: vec![
"Unlimited users".into(),
"All LLM providers".into(),
"Unlimited tokens".into(),
"Dedicated support".into(),
"Full observability".into(),
"Custom integrations".into(),
"SLA guarantee".into(),
"On-premise deployment".into(),
t(l, "pricing.unlimited_users"),
t(l, "pricing.all_providers"),
t(l, "pricing.unlimited_tokens"),
t(l, "pricing.dedicated_support"),
t(l, "pricing.full_observability"),
t(l, "pricing.custom_integrations"),
t(l, "pricing.sla"),
t(l, "pricing.on_premise"),
],
highlighted: false,
max_seats: None,

View File

@@ -2,6 +2,7 @@ use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
use dioxus_free_icons::Icon;
use crate::i18n::{t, Locale};
use crate::Route;
/// Privacy Policy page.
@@ -10,6 +11,9 @@ use crate::Route;
/// without authentication.
#[component]
pub fn PrivacyPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
div { class: "legal-page",
nav { class: "legal-nav",
@@ -21,85 +25,66 @@ pub fn PrivacyPage() -> Element {
}
}
main { class: "legal-content",
h1 { "Privacy Policy" }
p { class: "legal-updated", "Last updated: February 2026" }
h1 { "{t(l, \"privacy.title\")}" }
p { class: "legal-updated", "{t(l, \"privacy.last_updated\")}" }
h2 { "1. Introduction" }
p {
"CERTifAI GmbH (\"we\", \"our\", \"us\") is committed to "
"protecting your personal data. This privacy policy explains "
"how we collect, use, and safeguard your information when you "
"use our platform."
}
h2 { "{t(l, \"privacy.intro_title\")}" }
p { "{t(l, \"privacy.intro_text\")}" }
h2 { "2. Data Controller" }
h2 { "{t(l, \"privacy.controller_title\")}" }
p {
"CERTifAI GmbH"
"{t(l, \"impressum.company\")}"
br {}
"Musterstrasse 1, 10115 Berlin, Germany"
"{t(l, \"privacy.controller_address\")}"
br {}
"Email: privacy@certifai.example"
"{t(l, \"privacy.controller_email\")}"
}
h2 { "3. Data We Collect" }
p {
"We collect only the minimum data necessary to provide "
"our services:"
}
h2 { "{t(l, \"privacy.data_title\")}" }
p { "{t(l, \"privacy.data_intro\")}" }
ul {
li {
strong { "Account data: " }
"Name, email address, and organization details "
"provided during registration."
strong { "{t(l, \"privacy.data_account_label\")}" }
"{t(l, \"privacy.data_account_text\")}"
}
li {
strong { "Usage data: " }
"API call logs, token counts, and feature usage "
"metrics for billing and analytics."
strong { "{t(l, \"privacy.data_usage_label\")}" }
"{t(l, \"privacy.data_usage_text\")}"
}
li {
strong { "Technical data: " }
"IP addresses, browser type, and session identifiers "
"for security and platform stability."
strong { "{t(l, \"privacy.data_technical_label\")}" }
"{t(l, \"privacy.data_technical_text\")}"
}
}
h2 { "4. How We Use Your Data" }
h2 { "{t(l, \"privacy.use_title\")}" }
ul {
li { "To provide and maintain the CERTifAI platform" }
li { "To manage your account and subscription" }
li { "To communicate service updates and security notices" }
li { "To comply with legal obligations" }
li { "{t(l, \"privacy.use_1\")}" }
li { "{t(l, \"privacy.use_2\")}" }
li { "{t(l, \"privacy.use_3\")}" }
li { "{t(l, \"privacy.use_4\")}" }
}
h2 { "5. Data Storage and Sovereignty" }
p {
"CERTifAI is a self-hosted platform. All AI workloads, "
"model data, and inference results remain entirely within "
"your own infrastructure. We do not access, store, or "
"process your AI data on our servers."
}
h2 { "{t(l, \"privacy.storage_title\")}" }
p { "{t(l, \"privacy.storage_text\")}" }
h2 { "6. Your Rights (GDPR)" }
p { "Under the GDPR, you have the right to:" }
h2 { "{t(l, \"privacy.rights_title\")}" }
p { "{t(l, \"privacy.rights_intro\")}" }
ul {
li { "Access your personal data" }
li { "Rectify inaccurate data" }
li { "Request erasure of your data" }
li { "Restrict or object to processing" }
li { "Data portability" }
li { "Lodge a complaint with a supervisory authority" }
li { "{t(l, \"privacy.rights_access\")}" }
li { "{t(l, \"privacy.rights_rectify\")}" }
li { "{t(l, \"privacy.rights_erasure\")}" }
li { "{t(l, \"privacy.rights_restrict\")}" }
li { "{t(l, \"privacy.rights_portability\")}" }
li { "{t(l, \"privacy.rights_complaint\")}" }
}
h2 { "7. Contact" }
p {
"For privacy-related inquiries, contact us at "
"privacy@certifai.example."
}
h2 { "{t(l, \"privacy.contact_title\")}" }
p { "{t(l, \"privacy.contact_text\")}" }
}
footer { class: "legal-footer",
Link { to: Route::LandingPage {}, "Back to Home" }
Link { to: Route::ImpressumPage {}, "Impressum" }
Link { to: Route::LandingPage {}, "{t(l, \"common.back_to_home\")}" }
Link { to: Route::ImpressumPage {}, "{t(l, \"common.impressum\")}" }
}
}
}

View File

@@ -1,6 +1,7 @@
use dioxus::prelude::*;
use crate::components::PageHeader;
use crate::i18n::{t, Locale};
use crate::models::{EmbeddingEntry, LlmProvider, ModelEntry, ProviderConfig};
/// Providers page for configuring LLM and embedding model backends.
@@ -9,6 +10,9 @@ use crate::models::{EmbeddingEntry, LlmProvider, ModelEntry, ProviderConfig};
/// shows the currently active provider status.
#[component]
pub fn ProvidersPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let mut selected_provider = use_signal(|| LlmProvider::Ollama);
let mut selected_model = use_signal(|| "llama3.1:8b".to_string());
let mut selected_embedding = use_signal(|| "nomic-embed-text".to_string());
@@ -39,13 +43,13 @@ pub fn ProvidersPage() -> Element {
rsx! {
section { class: "providers-page",
PageHeader {
title: "Providers".to_string(),
subtitle: "Configure your LLM and embedding backends".to_string(),
title: t(l, "providers.title"),
subtitle: t(l, "providers.subtitle"),
}
div { class: "providers-layout",
div { class: "providers-form",
div { class: "form-group",
label { "Provider" }
label { "{t(l, \"providers.provider\")}" }
select {
class: "form-select",
value: "{provider_val.label()}",
@@ -67,7 +71,7 @@ pub fn ProvidersPage() -> Element {
}
}
div { class: "form-group",
label { "Model" }
label { "{t(l, \"providers.model\")}" }
select {
class: "form-select",
value: "{selected_model}",
@@ -81,7 +85,7 @@ pub fn ProvidersPage() -> Element {
}
}
div { class: "form-group",
label { "Embedding Model" }
label { "{t(l, \"providers.embedding_model\")}" }
select {
class: "form-select",
value: "{selected_embedding}",
@@ -95,11 +99,11 @@ pub fn ProvidersPage() -> Element {
}
}
div { class: "form-group",
label { "API Key" }
label { "{t(l, \"providers.api_key\")}" }
input {
class: "form-input",
r#type: "password",
placeholder: "Enter API key...",
placeholder: "{t(l, \"providers.api_key_placeholder\")}",
value: "{api_key}",
oninput: move |evt: Event<FormData>| {
api_key.set(evt.value());
@@ -110,34 +114,34 @@ pub fn ProvidersPage() -> Element {
button {
class: "btn-primary",
onclick: move |_| saved.set(true),
"Save Configuration"
"{t(l, \"providers.save_config\")}"
}
if *saved.read() {
p { class: "form-success", "Configuration saved." }
p { class: "form-success", "{t(l, \"providers.config_saved\")}" }
}
}
div { class: "providers-status",
h3 { "Active Configuration" }
h3 { "{t(l, \"providers.active_config\")}" }
div { class: "status-card",
div { class: "status-row",
span { class: "status-label", "Provider" }
span { class: "status-label", "{t(l, \"providers.provider\")}" }
span { class: "status-value", "{active_config.provider.label()}" }
}
div { class: "status-row",
span { class: "status-label", "Model" }
span { class: "status-label", "{t(l, \"providers.model\")}" }
span { class: "status-value", "{active_config.selected_model}" }
}
div { class: "status-row",
span { class: "status-label", "Embedding" }
span { class: "status-label", "{t(l, \"providers.embedding\")}" }
span { class: "status-value", "{active_config.selected_embedding}" }
}
div { class: "status-row",
span { class: "status-label", "API Key" }
span { class: "status-label", "{t(l, \"providers.api_key\")}" }
span { class: "status-value",
if active_config.api_key_set {
"Set"
"{t(l, \"common.set\")}"
} else {
"Not set"
"{t(l, \"common.not_set\")}"
}
}
}

View File

@@ -1,6 +1,9 @@
use std::collections::HashMap;
use dioxus::prelude::*;
use crate::components::{PageHeader, ToolCard};
use crate::i18n::{t, Locale};
use crate::models::{McpTool, ToolCategory, ToolStatus};
/// Tools page displaying a grid of MCP tool cards with toggle switches.
@@ -9,24 +12,50 @@ use crate::models::{McpTool, ToolCategory, ToolStatus};
/// enabling/disabling them via toggle buttons.
#[component]
pub fn ToolsPage() -> Element {
let mut tools = use_signal(mock_tools);
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// Toggle a tool's enabled state by its ID
let on_toggle = move |id: String| {
tools.write().iter_mut().for_each(|t| {
if t.id == id {
t.enabled = !t.enabled;
// Track which tool IDs have been toggled off/on by the user.
// The canonical tool definitions (including translated names) come
// from `mock_tools(l)` on every render so they react to locale changes.
let mut enabled_overrides = use_signal(HashMap::<String, bool>::new);
// Build the display list: translated names from mock_tools, with
// enabled state merged from user overrides.
let tool_list: Vec<McpTool> = mock_tools(l)
.into_iter()
.map(|mut tool| {
if let Some(&enabled) = enabled_overrides.read().get(&tool.id) {
tool.enabled = enabled;
}
});
};
tool
})
.collect();
let tool_list = tools.read().clone();
// Toggle a tool's enabled state by its ID.
// Reads the current state from overrides (or falls back to the default
// enabled value from mock_tools) and flips it.
let on_toggle = move |id: String| {
let defaults = mock_tools(l);
let current = enabled_overrides
.read()
.get(&id)
.copied()
.unwrap_or_else(|| {
defaults
.iter()
.find(|tool| tool.id == id)
.map(|tool| tool.enabled)
.unwrap_or(false)
});
enabled_overrides.write().insert(id, !current);
};
rsx! {
section { class: "tools-page",
PageHeader {
title: "Tools".to_string(),
subtitle: "Manage MCP servers and tool integrations".to_string(),
title: t(l, "tools.title"),
subtitle: t(l, "tools.subtitle"),
}
div { class: "tools-grid",
for tool in tool_list {
@@ -37,13 +66,17 @@ pub fn ToolsPage() -> Element {
}
}
/// Returns mock MCP tools for the tools grid.
fn mock_tools() -> Vec<McpTool> {
/// Returns mock MCP tools for the tools grid with translated names.
///
/// # Arguments
///
/// * `l` - The current locale for translating tool names and descriptions
fn mock_tools(l: Locale) -> Vec<McpTool> {
vec![
McpTool {
id: "calculator".into(),
name: "Calculator".into(),
description: "Mathematical computation and unit conversion".into(),
name: t(l, "tools.calculator"),
description: t(l, "tools.calculator_desc"),
category: ToolCategory::Compute,
status: ToolStatus::Active,
enabled: true,
@@ -51,8 +84,8 @@ fn mock_tools() -> Vec<McpTool> {
},
McpTool {
id: "tavily".into(),
name: "Tavily Search".into(),
description: "AI-optimized web search API for real-time information".into(),
name: t(l, "tools.tavily"),
description: t(l, "tools.tavily_desc"),
category: ToolCategory::Search,
status: ToolStatus::Active,
enabled: true,
@@ -60,8 +93,8 @@ fn mock_tools() -> Vec<McpTool> {
},
McpTool {
id: "searxng".into(),
name: "SearXNG".into(),
description: "Privacy-respecting metasearch engine".into(),
name: t(l, "tools.searxng"),
description: t(l, "tools.searxng_desc"),
category: ToolCategory::Search,
status: ToolStatus::Active,
enabled: true,
@@ -69,8 +102,8 @@ fn mock_tools() -> Vec<McpTool> {
},
McpTool {
id: "file-reader".into(),
name: "File Reader".into(),
description: "Read and parse local files in various formats".into(),
name: t(l, "tools.file_reader"),
description: t(l, "tools.file_reader_desc"),
category: ToolCategory::FileSystem,
status: ToolStatus::Active,
enabled: true,
@@ -78,8 +111,8 @@ fn mock_tools() -> Vec<McpTool> {
},
McpTool {
id: "code-exec".into(),
name: "Code Executor".into(),
description: "Sandboxed code execution for Python and JavaScript".into(),
name: t(l, "tools.code_executor"),
description: t(l, "tools.code_executor_desc"),
category: ToolCategory::Code,
status: ToolStatus::Inactive,
enabled: false,
@@ -87,8 +120,8 @@ fn mock_tools() -> Vec<McpTool> {
},
McpTool {
id: "web-scraper".into(),
name: "Web Scraper".into(),
description: "Extract structured data from web pages".into(),
name: t(l, "tools.web_scraper"),
description: t(l, "tools.web_scraper_desc"),
category: ToolCategory::Search,
status: ToolStatus::Active,
enabled: true,
@@ -96,8 +129,8 @@ fn mock_tools() -> Vec<McpTool> {
},
McpTool {
id: "email".into(),
name: "Email Sender".into(),
description: "Send emails via configured SMTP server".into(),
name: t(l, "tools.email_sender"),
description: t(l, "tools.email_sender_desc"),
category: ToolCategory::Communication,
status: ToolStatus::Inactive,
enabled: false,
@@ -105,8 +138,8 @@ fn mock_tools() -> Vec<McpTool> {
},
McpTool {
id: "git".into(),
name: "Git Operations".into(),
description: "Interact with Git repositories for version control".into(),
name: t(l, "tools.git_ops"),
description: t(l, "tools.git_ops_desc"),
category: ToolCategory::Code,
status: ToolStatus::Active,
enabled: true,