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:
22
src/app.rs
22
src/app.rs
@@ -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 {
|
||||
|
||||
@@ -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\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
242
src/i18n/mod.rs
Normal file
242
src/i18n/mod.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}" }
|
||||
|
||||
@@ -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\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")} }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user