feat: use librechat instead of own chat (#14)
All checks were successful
CI / Format (push) Successful in 2s
CI / Clippy (push) Successful in 2m48s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 4m11s
CI / Deploy (push) Successful in 4s

Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2026-02-24 10:45:41 +00:00
parent d814e22f9d
commit 208450e618
33 changed files with 968 additions and 2124 deletions

View File

@@ -1,69 +0,0 @@
use crate::i18n::{t, Locale};
use dioxus::prelude::*;
use dioxus_free_icons::icons::fa_solid_icons::{FaCopy, FaPenToSquare, FaShareNodes};
/// Action bar displayed above the chat input with copy, share, and edit buttons.
///
/// Only visible when there is at least one message in the conversation.
///
/// # Arguments
///
/// * `on_copy` - Copies the last assistant response to the clipboard
/// * `on_share` - Copies the full conversation as text to the clipboard
/// * `on_edit` - Places the last user message back in the input for editing
/// * `has_messages` - Whether any messages exist (hides the bar when empty)
/// * `has_assistant_message` - Whether an assistant message exists (disables copy if not)
/// * `has_user_message` - Whether a user message exists (disables edit if not)
#[component]
pub fn ChatActionBar(
on_copy: EventHandler<()>,
on_share: EventHandler<()>,
on_edit: EventHandler<()>,
has_messages: bool,
has_assistant_message: bool,
has_user_message: bool,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
if !has_messages {
return rsx! {};
}
rsx! {
div { class: "chat-action-bar",
button {
class: "chat-action-btn",
disabled: !has_assistant_message,
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", "{t(l, \"common.copy\")}" }
}
button {
class: "chat-action-btn",
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", "{t(l, \"common.share\")}" }
}
button {
class: "chat-action-btn",
disabled: !has_user_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", "{t(l, \"common.edit\")}" }
}
}
}
}

View File

@@ -1,142 +0,0 @@
use crate::i18n::{t, Locale};
use crate::models::{ChatMessage, ChatRole};
use dioxus::prelude::*;
/// Render markdown content to HTML using `pulldown-cmark`.
///
/// # Arguments
///
/// * `md` - Raw markdown string
///
/// # Returns
///
/// HTML string suitable for `dangerous_inner_html`
fn markdown_to_html(md: &str) -> String {
use pulldown_cmark::{Options, Parser};
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
let parser = Parser::new_ext(md, opts);
let mut html = String::with_capacity(md.len() * 2);
pulldown_cmark::html::push_html(&mut html, parser);
html
}
/// Renders a single chat message bubble with role-based styling.
///
/// User messages are displayed as plain text, right-aligned.
/// Assistant messages are rendered as markdown with `pulldown-cmark`.
/// System messages are hidden from the UI.
///
/// # Arguments
///
/// * `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! {};
}
let bubble_class = match message.role {
ChatRole::User => "chat-bubble chat-bubble--user",
ChatRole::Assistant => "chat-bubble chat-bubble--assistant",
ChatRole::System => unreachable!(),
};
let role_label = match message.role {
ChatRole::User => t(l, "chat.you"),
ChatRole::Assistant => t(l, "chat.assistant"),
ChatRole::System => unreachable!(),
};
// Format timestamp for display (show time only if today)
let display_time = if message.timestamp.len() >= 16 {
// Extract HH:MM from ISO 8601
message.timestamp[11..16].to_string()
} else {
message.timestamp.clone()
};
let is_assistant = message.role == ChatRole::Assistant;
rsx! {
div { class: "{bubble_class}",
div { class: "chat-bubble-header",
span { class: "chat-bubble-role", "{role_label}" }
span { class: "chat-bubble-time", "{display_time}" }
}
if is_assistant {
// Render markdown for assistant messages
div {
class: "chat-bubble-content chat-prose",
dangerous_inner_html: "{markdown_to_html(&message.content)}",
}
} else {
div { class: "chat-bubble-content", "{message.content}" }
}
if !message.attachments.is_empty() {
div { class: "chat-bubble-attachments",
for att in &message.attachments {
span { class: "chat-attachment", "{att.name}" }
}
}
}
}
}
}
/// Renders a streaming assistant message bubble.
///
/// While waiting for tokens, shows a "Thinking..." indicator with
/// a pulsing dot animation. Once tokens arrive, renders them as
/// markdown with a blinking cursor.
///
/// # Arguments
///
/// * `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! {
div { class: "chat-bubble chat-bubble--assistant chat-bubble--thinking",
div { class: "chat-thinking",
span { class: "chat-thinking-dots",
span { class: "chat-dot" }
span { class: "chat-dot" }
span { class: "chat-dot" }
}
span { class: "chat-thinking-text",
"{t(l, \"chat.thinking\")}"
}
}
}
}
} else {
let html = markdown_to_html(&content);
rsx! {
div { class: "chat-bubble chat-bubble--assistant chat-bubble--streaming",
div { class: "chat-bubble-header",
span { class: "chat-bubble-role",
"{t(l, \"chat.assistant\")}"
}
}
div {
class: "chat-bubble-content chat-prose",
dangerous_inner_html: "{html}",
}
span { class: "chat-streaming-cursor" }
}
}
}
}

View File

@@ -1,73 +0,0 @@
use crate::i18n::{t, Locale};
use dioxus::prelude::*;
/// Chat input bar with a textarea and send button.
///
/// Enter sends the message; Shift+Enter inserts a newline.
/// The input is disabled during streaming.
///
/// # Arguments
///
/// * `input_text` - Two-way bound input text signal
/// * `on_send` - Callback fired with the message text when sent
/// * `is_streaming` - Whether to disable the input (streaming in progress)
#[component]
pub fn ChatInputBar(
input_text: Signal<String>,
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: "{t(l, \"chat.type_message\")}",
disabled: is_streaming,
rows: "1",
value: "{input}",
oninput: move |e: Event<FormData>| {
input.set(e.value());
},
onkeypress: move |e: Event<KeyboardData>| {
// Enter sends, Shift+Enter adds newline
if e.key() == Key::Enter && !e.modifiers().shift() {
e.prevent_default();
let text = input.read().trim().to_string();
if !text.is_empty() {
on_send.call(text);
input.set(String::new());
}
}
},
}
button {
class: "btn-primary chat-send-btn",
disabled: is_streaming || input.read().trim().is_empty(),
onclick: move |_| {
let text = input.read().trim().to_string();
if !text.is_empty() {
on_send.call(text);
input.set(String::new());
}
},
if is_streaming {
// Stop icon during streaming
dioxus_free_icons::Icon {
icon: dioxus_free_icons::icons::fa_solid_icons::FaStop,
width: 16, height: 16,
}
} else {
dioxus_free_icons::Icon {
icon: dioxus_free_icons::icons::fa_solid_icons::FaPaperPlane,
width: 16, height: 16,
}
}
}
}
}
}

View File

@@ -1,42 +0,0 @@
use crate::components::{ChatBubble, StreamingBubble};
use crate::i18n::{t, Locale};
use crate::models::ChatMessage;
use dioxus::prelude::*;
/// Scrollable message list that renders all messages in a chat session.
///
/// Auto-scrolls to the bottom when new messages arrive or during streaming.
/// Shows a streaming bubble with a blinking cursor when `is_streaming` is true.
///
/// # Arguments
///
/// * `messages` - All loaded messages for the current session
/// * `streaming_content` - Accumulated content from the SSE stream
/// * `is_streaming` - Whether a response is currently streaming
#[component]
pub fn ChatMessageList(
messages: Vec<ChatMessage>,
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 { "{t(l, \"chat.send_to_start\")}" }
}
}
for msg in &messages {
ChatBubble { key: "{msg.id}", message: msg.clone() }
}
if is_streaming {
StreamingBubble { content: streaming_content }
}
}
}
}

View File

@@ -1,50 +0,0 @@
use crate::i18n::{t, Locale};
use dioxus::prelude::*;
/// Dropdown bar for selecting the LLM model for the current chat session.
///
/// Displays the currently selected model and a list of available models
/// from the Ollama instance. Fires `on_change` when the user selects
/// a different model.
///
/// # Arguments
///
/// * `selected_model` - The currently active model ID
/// * `available_models` - List of model names from Ollama
/// * `on_change` - Callback fired with the new model name
#[component]
pub fn ChatModelSelector(
selected_model: String,
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",
"{t(l, \"chat.model_label\")}"
}
select {
class: "chat-model-select",
value: "{selected_model}",
onchange: move |e: Event<FormData>| {
on_change.call(e.value());
},
for model in &available_models {
option {
value: "{model}",
selected: *model == selected_model,
"{model}"
}
}
if available_models.is_empty() {
option { disabled: true,
"{t(l, \"chat.no_models\")}"
}
}
}
}
}
}

View File

@@ -1,258 +0,0 @@
use crate::i18n::{t, tw, Locale};
use crate::models::{ChatNamespace, ChatSession};
use dioxus::prelude::*;
/// Chat sidebar displaying grouped session list with actions.
///
/// Sessions are split into "News Chats" and "General" sections.
/// Each session item shows the title and relative date, with
/// rename and delete actions on hover.
///
/// # Arguments
///
/// * `sessions` - All chat sessions for the user
/// * `active_session_id` - Currently selected session ID (highlighted)
/// * `on_select` - Callback when a session is clicked
/// * `on_new` - Callback to create a new chat session
/// * `on_rename` - Callback with `(session_id, new_title)`
/// * `on_delete` - Callback with `session_id`
#[component]
pub fn ChatSidebar(
sessions: Vec<ChatSession>,
active_session_id: Option<String>,
on_select: EventHandler<String>,
on_new: EventHandler<()>,
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()
.filter(|s| s.namespace == ChatNamespace::News)
.collect();
let general_sessions: Vec<&ChatSession> = sessions
.iter()
.filter(|s| s.namespace == ChatNamespace::General)
.collect();
// Signal for inline rename state: Option<(session_id, current_value)>
let rename_state: Signal<Option<(String, String)>> = use_signal(|| None);
rsx! {
div { class: "chat-sidebar-panel",
div { class: "chat-sidebar-header",
h3 { "{t(l, \"chat.conversations\")}" }
button {
class: "btn-icon",
title: "{t(l, \"chat.new_chat\")}",
onclick: move |_| on_new.call(()),
"+"
}
}
div { class: "chat-session-list",
// News Chats section
if !news_sessions.is_empty() {
div { class: "chat-namespace-header",
"{t(l, \"chat.news_chats\")}"
}
for session in &news_sessions {
SessionItem {
session: (*session).clone(),
is_active: active_session_id.as_deref() == Some(&session.id),
rename_state: rename_state,
on_select: on_select,
on_rename: on_rename,
on_delete: on_delete,
}
}
}
// General section
div { class: "chat-namespace-header",
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",
"{t(l, \"chat.no_conversations\")}"
}
}
for session in &general_sessions {
SessionItem {
session: (*session).clone(),
is_active: active_session_id.as_deref() == Some(&session.id),
rename_state: rename_state,
on_select: on_select,
on_rename: on_rename,
on_delete: on_delete,
}
}
}
}
}
}
/// Individual session item component. Handles rename inline editing.
#[component]
fn SessionItem(
session: ChatSession,
is_active: bool,
rename_state: Signal<Option<(String, String)>>,
on_select: EventHandler<String>,
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"
} else {
"chat-session-item"
};
let is_renaming = rename_sig
.read()
.as_ref()
.is_some_and(|(id, _)| id == &session.id);
let session_id = session.id.clone();
let session_title = session.title.clone();
let date_display = format_relative_date(&session.updated_at, l);
if is_renaming {
let rename_value = rename_sig
.read()
.as_ref()
.map(|(_, v)| v.clone())
.unwrap_or_default();
let sid = session_id.clone();
rsx! {
div { class: "{item_class}",
input {
class: "chat-session-rename-input",
r#type: "text",
value: "{rename_value}",
autofocus: true,
oninput: move |e: Event<FormData>| {
let val = e.value();
let id = sid.clone();
rename_sig.set(Some((id, val)));
},
onkeypress: move |e: Event<KeyboardData>| {
if e.key() == Key::Enter {
if let Some((id, val)) = rename_sig.read().clone() {
if !val.trim().is_empty() {
on_rename.call((id, val));
}
}
rename_sig.set(None);
} else if e.key() == Key::Escape {
rename_sig.set(None);
}
},
onfocusout: move |_| {
if let Some((ref id, ref val)) = *rename_sig.read() {
if !val.trim().is_empty() {
on_rename.call((id.clone(), val.clone()));
}
}
rename_sig.set(None);
},
}
}
}
} else {
let sid_select = session_id.clone();
let sid_delete = session_id.clone();
let sid_rename = session_id.clone();
let title_for_rename = session_title.clone();
rsx! {
div {
class: "{item_class}",
onclick: move |_| on_select.call(sid_select.clone()),
div { class: "chat-session-info",
span { class: "chat-session-title", "{session_title}" }
span { class: "chat-session-date", "{date_display}" }
}
div { class: "chat-session-actions",
button {
class: "btn-icon-sm",
title: "{t(l, \"common.rename\")}",
onclick: move |e: Event<MouseData>| {
e.stop_propagation();
rename_sig.set(Some((
sid_rename.clone(),
title_for_rename.clone(),
)));
},
dioxus_free_icons::Icon {
icon: dioxus_free_icons::icons::fa_solid_icons::FaPen,
width: 12, height: 12,
}
}
button {
class: "btn-icon-sm btn-icon-danger",
title: "{t(l, \"common.delete\")}",
onclick: move |e: Event<MouseData>| {
e.stop_propagation();
on_delete.call(sid_delete.clone());
},
dioxus_free_icons::Icon {
icon: dioxus_free_icons::icons::fa_solid_icons::FaTrash,
width: 12, height: 12,
}
}
}
}
}
}
}
/// Format an ISO 8601 timestamp as a relative date 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 {
t(locale, "chat.just_now")
} else if diff.num_hours() < 1 {
tw(
locale,
"chat.minutes_ago",
&[("n", &diff.num_minutes().to_string())],
)
} else if diff.num_hours() < 24 {
tw(
locale,
"chat.hours_ago",
&[("n", &diff.num_hours().to_string())],
)
} else if diff.num_days() < 7 {
tw(
locale,
"chat.days_ago",
&[("n", &diff.num_days().to_string())],
)
} else {
dt.format("%b %d").to_string()
}
} else {
iso.to_string()
}
}

View File

@@ -1,59 +0,0 @@
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
use crate::models::KnowledgeFile;
/// Renders a table row for a knowledge base file.
///
/// # Arguments
///
/// * `file` - The knowledge file data to render
/// * `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);
rsx! {
tr { class: "file-row",
td { class: "file-row-name",
span { class: "file-row-icon", "{file.kind.icon()}" }
"{file.name}"
}
td { "{file.kind.label()}" }
td { "{size_display}" }
td { "{file.chunk_count} {t(l, \"common.chunks\")}" }
td { "{file.uploaded_at}" }
td {
button {
class: "btn-icon btn-danger",
onclick: {
let id = file.id.clone();
move |_| on_delete.call(id.clone())
},
"{t(l, \"common.delete\")}"
}
}
}
}
}
/// Formats a byte count into a human-readable string (e.g. "1.2 MB").
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{bytes} B")
}
}

View File

@@ -1,14 +1,7 @@
mod app_shell;
mod article_detail;
mod card;
mod chat_action_bar;
mod chat_bubble;
mod chat_input_bar;
mod chat_message_list;
mod chat_model_selector;
mod chat_sidebar;
mod dashboard_sidebar;
mod file_row;
mod login;
mod member_row;
pub mod news_card;
@@ -16,23 +9,14 @@ mod page_header;
mod pricing_card;
pub mod sidebar;
pub mod sub_nav;
mod tool_card;
pub use app_shell::*;
pub use article_detail::*;
pub use card::*;
pub use chat_action_bar::*;
pub use chat_bubble::*;
pub use chat_input_bar::*;
pub use chat_message_list::*;
pub use chat_model_selector::*;
pub use chat_sidebar::*;
pub use dashboard_sidebar::*;
pub use file_row::*;
pub use login::*;
pub use member_row::*;
pub use news_card::*;
pub use page_header::*;
pub use pricing_card::*;
pub use sub_nav::*;
pub use tool_card::*;

View File

@@ -1,13 +1,21 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub,
BsGlobe2, BsGrid, BsHouseDoor, BsMoonFill, BsPuzzle, BsSunFill,
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
@@ -15,7 +23,7 @@ use crate::Route;
struct NavItem {
key: &'static str,
label: String,
route: Route,
target: NavTarget,
/// Bootstrap icon element rendered beside the label.
icon: Element,
}
@@ -45,43 +53,32 @@ pub fn Sidebar(
NavItem {
key: "dashboard",
label: t(locale_val, "nav.dashboard"),
route: Route::DashboardPage {},
target: NavTarget::Internal(Route::DashboardPage {}),
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
},
NavItem {
key: "providers",
label: t(locale_val, "nav.providers"),
route: Route::ProvidersPage {},
target: NavTarget::Internal(Route::ProvidersPage {}),
icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } },
},
NavItem {
key: "chat",
label: t(locale_val, "nav.chat"),
route: Route::ChatPage {},
// 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: "tools",
label: t(locale_val, "nav.tools"),
route: Route::ToolsPage {},
icon: rsx! { Icon { icon: BsPuzzle, width: 18, height: 18 } },
},
NavItem {
key: "knowledge_base",
label: t(locale_val, "nav.knowledge_base"),
route: Route::KnowledgePage {},
icon: rsx! { Icon { icon: BsCollection, width: 18, height: 18 } },
},
NavItem {
key: "developer",
label: t(locale_val, "nav.developer"),
route: Route::AgentsPage {},
target: NavTarget::Internal(Route::AgentsPage {}),
icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } },
},
NavItem {
key: "organization",
label: t(locale_val, "nav.organization"),
route: Route::OrgPricingPage {},
target: NavTarget::Internal(Route::OrgPricingPage {}),
icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } },
},
];
@@ -100,25 +97,45 @@ pub fn Sidebar(
nav { class: "sidebar-nav",
for item in nav_items {
{
// Active detection for nested routes: highlight the parent nav
// item when any child route within the nested shell is active.
let is_active = match &current_route {
Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => {
item.key == "developer"
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 &current_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}" }
}
}
}
Route::OrgPricingPage {} | Route::OrgDashboardPage {} => {
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,
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}" }
}
}
}
}
}

View File

@@ -1,49 +0,0 @@
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
///
/// * `tool` - The MCP tool data to render
/// * `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"
} else {
"tool-toggle tool-toggle--off"
};
rsx! {
div { class: "tool-card",
div { class: "tool-card-header",
div { class: "tool-card-icon", "\u{2699}" }
span { class: "{status_class}", "" }
}
h3 { class: "tool-card-name", "{tool.name}" }
p { class: "tool-card-desc", "{tool.description}" }
div { class: "tool-card-footer",
span { class: "tool-card-category", "{tool.category.label()}" }
button {
class: "{toggle_class}",
onclick: {
let id = tool.id.clone();
move |_| on_toggle.call(id.clone())
},
if tool.enabled {
"{t(l, \"common.on\")}"
} else {
"{t(l, \"common.off\")}"
}
}
}
}
}
}