Replaces the custom chat page with an external LibreChat instance that shares Keycloak SSO for seamless auto-login. Removes Tools and Knowledge Base pages as these are now handled by LibreChat's built-in capabilities. - Add LibreChat service to docker-compose with Ollama backend config - Add Keycloak OIDC client (certifai-librechat) with prompt=none for silent SSO - Create librechat.yaml with CERTifAI branding, Ollama endpoint, and custom page title/logo - Change sidebar Chat link to external URL (opens LibreChat in new tab) - Remove chat page, tools page, knowledge base page and all related components (chat_sidebar, chat_bubble, chat_input_bar, etc.) - Remove tool_card, file_row components and tool/knowledge models - Remove chat_stream SSE handler (no longer needed) - Clean up i18n files: remove chat, tools, knowledge sections - Dashboard article summarization via Ollama remains intact Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
394 lines
14 KiB
Rust
394 lines
14 KiB
Rust
use dioxus::prelude::*;
|
|
use dioxus_free_icons::icons::bs_icons::{
|
|
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsGithub, BsGlobe2,
|
|
BsGrid, BsHouseDoor, BsMoonFill, BsSunFill,
|
|
};
|
|
use dioxus_free_icons::Icon;
|
|
|
|
use crate::i18n::{t, Locale};
|
|
use crate::Route;
|
|
|
|
/// Destination for a sidebar link: either an internal route or an external URL.
|
|
enum NavTarget {
|
|
/// Internal Dioxus route (rendered as `Link { to: route }`).
|
|
Internal(Route),
|
|
/// External URL opened in a new tab (rendered as `<a href>`).
|
|
External(&'static str),
|
|
}
|
|
|
|
/// 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 {
|
|
key: &'static str,
|
|
label: String,
|
|
target: NavTarget,
|
|
/// Bootstrap icon element rendered beside the label.
|
|
icon: Element,
|
|
}
|
|
|
|
/// Fixed left sidebar containing header, navigation, logout, and footer.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `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,
|
|
#[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 {
|
|
key: "dashboard",
|
|
label: t(locale_val, "nav.dashboard"),
|
|
target: NavTarget::Internal(Route::DashboardPage {}),
|
|
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
|
|
},
|
|
NavItem {
|
|
key: "providers",
|
|
label: t(locale_val, "nav.providers"),
|
|
target: NavTarget::Internal(Route::ProvidersPage {}),
|
|
icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } },
|
|
},
|
|
NavItem {
|
|
key: "chat",
|
|
label: t(locale_val, "nav.chat"),
|
|
// Opens LibreChat in a new tab; SSO via shared Keycloak realm.
|
|
target: NavTarget::External("http://localhost:3080"),
|
|
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
|
|
},
|
|
NavItem {
|
|
key: "developer",
|
|
label: t(locale_val, "nav.developer"),
|
|
target: NavTarget::Internal(Route::AgentsPage {}),
|
|
icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } },
|
|
},
|
|
NavItem {
|
|
key: "organization",
|
|
label: t(locale_val, "nav.organization"),
|
|
target: NavTarget::Internal(Route::OrgPricingPage {}),
|
|
icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } },
|
|
},
|
|
];
|
|
|
|
// 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: "{class}",
|
|
div { class: "sidebar-top-row",
|
|
SidebarHeader { name, email: email.clone(), avatar_url }
|
|
LocalePicker {}
|
|
}
|
|
|
|
nav { class: "sidebar-nav",
|
|
for item in nav_items {
|
|
{
|
|
match &item.target {
|
|
NavTarget::Internal(route) => {
|
|
// Active detection for nested routes: highlight the parent
|
|
// nav item when any child route within the nested shell
|
|
// is active.
|
|
let is_active = match ¤t_route {
|
|
Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => {
|
|
item.key == "developer"
|
|
}
|
|
Route::OrgPricingPage {} | Route::OrgDashboardPage {} => {
|
|
item.key == "organization"
|
|
}
|
|
_ => *route == current_route,
|
|
};
|
|
let cls = if is_active { "sidebar-link active" } else { "sidebar-link" };
|
|
let route = route.clone();
|
|
rsx! {
|
|
Link {
|
|
to: route,
|
|
class: cls,
|
|
onclick: move |_| on_nav.call(()),
|
|
{item.icon}
|
|
span { "{item.label}" }
|
|
}
|
|
}
|
|
}
|
|
NavTarget::External(url) => {
|
|
let url = *url;
|
|
rsx! {
|
|
a {
|
|
href: url,
|
|
target: "_blank",
|
|
rel: "noopener noreferrer",
|
|
class: "sidebar-link",
|
|
onclick: move |_| on_nav.call(()),
|
|
{item.icon}
|
|
span { "{item.label}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
div { class: "sidebar-bottom-actions",
|
|
Link {
|
|
to: NavigationTarget::<Route>::External("/logout".into()),
|
|
class: "sidebar-link logout-btn",
|
|
Icon { icon: BsBoxArrowRight, width: 18, height: 18 }
|
|
span { "{logout_label}" }
|
|
}
|
|
ThemeToggle {}
|
|
}
|
|
|
|
SidebarFooter {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Avatar circle, name, and email display at the top of the sidebar.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `name` - User display name. If non-empty, shown above the email.
|
|
/// * `email` - User email to display.
|
|
/// * `avatar_url` - Placeholder for future avatar image URL.
|
|
#[component]
|
|
fn SidebarHeader(name: String, email: String, avatar_url: String) -> Element {
|
|
// Derive initials: prefer name words, fall back to email prefix.
|
|
let initials: String = if name.is_empty() {
|
|
email
|
|
.split('@')
|
|
.next()
|
|
.unwrap_or("U")
|
|
.chars()
|
|
.take(2)
|
|
.collect::<String>()
|
|
.to_uppercase()
|
|
} else {
|
|
name.split_whitespace()
|
|
.filter_map(|w| w.chars().next())
|
|
.take(2)
|
|
.collect::<String>()
|
|
.to_uppercase()
|
|
};
|
|
|
|
rsx! {
|
|
div { class: "sidebar-header",
|
|
div { class: "avatar-circle",
|
|
span { class: "avatar-initials", "{initials}" }
|
|
}
|
|
div { class: "sidebar-user-info",
|
|
if !name.is_empty() {
|
|
p { class: "sidebar-name", "{name}" }
|
|
}
|
|
p { class: "sidebar-email", "{email}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Toggle button that switches between dark and light themes.
|
|
///
|
|
/// Sets `data-theme` on the `<html>` element and persists the choice
|
|
/// 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")]
|
|
{
|
|
web_sys::window()
|
|
.and_then(|w| w.local_storage().ok().flatten())
|
|
.and_then(|s| s.get_item("theme").ok().flatten())
|
|
.is_none_or(|v| v != "certifai-light")
|
|
}
|
|
#[cfg(not(feature = "web"))]
|
|
{
|
|
true
|
|
}
|
|
});
|
|
|
|
// Apply the persisted theme to the DOM on first render so the
|
|
// page doesn't flash dark if the user previously chose light.
|
|
#[cfg(feature = "web")]
|
|
{
|
|
let dark = *is_dark.read();
|
|
use_effect(move || {
|
|
let theme = if dark {
|
|
"certifai-dark"
|
|
} else {
|
|
"certifai-light"
|
|
};
|
|
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
|
|
if let Some(el) = doc.document_element() {
|
|
let _ = el.set_attribute("data-theme", theme);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
let toggle = move |_| {
|
|
let new_dark = !*is_dark.read();
|
|
is_dark.set(new_dark);
|
|
|
|
#[cfg(feature = "web")]
|
|
{
|
|
let theme = if new_dark {
|
|
"certifai-dark"
|
|
} else {
|
|
"certifai-light"
|
|
};
|
|
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
|
|
if let Some(el) = doc.document_element() {
|
|
let _ = el.set_attribute("data-theme", theme);
|
|
}
|
|
}
|
|
if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten())
|
|
{
|
|
let _ = storage.set_item("theme", theme);
|
|
}
|
|
}
|
|
};
|
|
|
|
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: "{title}",
|
|
onclick: toggle,
|
|
if dark {
|
|
Icon { icon: BsSunFill, width: 16, height: 16 }
|
|
} else {
|
|
Icon { icon: BsMoonFill, width: 16, height: 16 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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_title}",
|
|
Icon { icon: BsGithub, width: 16, height: 16 }
|
|
}
|
|
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_label}" }
|
|
span { class: "legal-sep", "|" }
|
|
Link { to: Route::ImpressumPage {}, class: "legal-link", "{impressum_label}" }
|
|
}
|
|
p { class: "sidebar-version", "v{version}" }
|
|
}
|
|
}
|
|
}
|