feat(dashboard): added dashboard content and features (#7)
All checks were successful
CI / Format (push) Successful in 2s
CI / Clippy (push) Successful in 2m18s
CI / Security Audit (push) Successful in 1m40s
CI / Tests (push) Successful in 2m51s
CI / Deploy (push) Successful in 2s

Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-02-19 19:23:06 +00:00
parent a588be306a
commit 5399afd748
20 changed files with 3111 additions and 131 deletions

View File

@@ -0,0 +1,158 @@
use crate::infrastructure::llm::FollowUpMessage;
use crate::models::NewsCard;
use dioxus::prelude::*;
/// Side panel displaying the full details of a selected news article.
///
/// Shows the article title, source, date, category badge, full content,
/// a link to the original article, an AI summary bubble, and a follow-up
/// chat window for asking questions about the article.
///
/// # Arguments
///
/// * `card` - The selected news card data
/// * `on_close` - Handler to close the detail panel
/// * `summary` - Optional AI-generated summary text
/// * `is_summarizing` - Whether a summarization request is in progress
/// * `chat_messages` - Follow-up chat conversation history (user + assistant turns)
/// * `is_chatting` - Whether a chat response is being generated
/// * `on_chat_send` - Handler called with the user's follow-up question
#[component]
pub fn ArticleDetail(
card: NewsCard,
on_close: EventHandler,
summary: Option<String>,
#[props(default = false)] is_summarizing: bool,
chat_messages: Vec<FollowUpMessage>,
#[props(default = false)] is_chatting: bool,
on_chat_send: EventHandler<String>,
) -> Element {
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);
let has_summary = summary.is_some() && !is_summarizing;
// Build favicon URL using DuckDuckGo's privacy-friendly icon service
let favicon_url = format!("https://icons.duckduckgo.com/ip3/{}.ico", card.source);
rsx! {
aside { class: "article-detail-panel",
// Close button
button {
class: "article-detail-close",
onclick: move |_| on_close.call(()),
"X"
}
div { class: "article-detail-content",
// Header
h2 { class: "article-detail-title", "{card.title}" }
div { class: "article-detail-meta",
span { class: "{badge_class}", "{card.category}" }
span { class: "article-detail-source",
img {
class: "source-favicon",
src: "{favicon_url}",
alt: "",
width: "16",
height: "16",
}
"{card.source}"
}
span { class: "article-detail-date", "{card.published_at}" }
}
// Content body
div { class: "article-detail-body",
p { "{card.content}" }
}
// Link to original
a {
class: "article-detail-link",
href: "{card.url}",
target: "_blank",
rel: "noopener",
"Read original article"
}
// AI Summary bubble (below the link)
div { class: "ai-summary-bubble",
if is_summarizing {
div { class: "ai-summary-bubble-loading",
div { class: "ai-summary-dot-pulse" }
span { "Summarizing..." }
}
} else if let Some(ref text) = summary {
p { class: "ai-summary-bubble-text", "{text}" }
span { class: "ai-summary-bubble-label", "Summarized with AI" }
}
}
// Follow-up chat window (visible after summary is ready)
if has_summary {
div { class: "article-chat",
// Chat message history
if !chat_messages.is_empty() {
div { class: "article-chat-messages",
for msg in chat_messages.iter() {
{
let bubble_class = if msg.role == "user" {
"chat-msg chat-msg--user"
} else {
"chat-msg chat-msg--assistant"
};
rsx! {
div { class: "{bubble_class}",
p { "{msg.content}" }
}
}
}
}
if is_chatting {
div { class: "chat-msg chat-msg--assistant chat-msg--typing",
div { class: "ai-summary-dot-pulse" }
}
}
}
}
// Chat input
div { class: "article-chat-input",
input {
class: "article-chat-textbox",
r#type: "text",
placeholder: "Ask a follow-up question...",
value: "{chat_input}",
disabled: is_chatting,
oninput: move |e| chat_input.set(e.value()),
onkeypress: move |e| {
if e.key() == Key::Enter && !is_chatting {
let val = chat_input.read().trim().to_string();
if !val.is_empty() {
on_chat_send.call(val);
chat_input.set(String::new());
}
}
},
}
button {
class: "article-chat-send",
disabled: is_chatting,
onclick: move |_| {
let val = chat_input.read().trim().to_string();
if !val.is_empty() {
on_chat_send.call(val);
chat_input.set(String::new());
}
},
"Send"
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,112 @@
use dioxus::prelude::*;
use crate::infrastructure::ollama::{get_ollama_status, OllamaStatus};
/// Right sidebar for the dashboard, showing Ollama status, trending topics,
/// and recent search history.
///
/// Appears when no article card is selected. Disappears when the user opens
/// the article detail split view.
///
/// # Props
///
/// * `ollama_url` - Ollama instance URL for status polling
/// * `trending` - Trending topic keywords extracted from recent news headlines
/// * `recent_searches` - Recent search topics stored in localStorage
/// * `on_topic_click` - Fires when a trending or recent topic is clicked
#[component]
pub fn DashboardSidebar(
ollama_url: String,
trending: Vec<String>,
recent_searches: Vec<String>,
on_topic_click: EventHandler<String>,
) -> Element {
// 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).
let url = ollama_url.clone();
let status_resource = use_resource(move || {
let u = url.clone();
async move {
get_ollama_status(u).await.unwrap_or(OllamaStatus {
online: false,
models: Vec::new(),
})
}
});
let current_status: OllamaStatus =
status_resource
.read()
.as_ref()
.cloned()
.unwrap_or(OllamaStatus {
online: false,
models: Vec::new(),
});
rsx! {
aside { class: "dashboard-sidebar",
// -- Ollama Status Section --
div { class: "sidebar-section",
h4 { class: "sidebar-section-title", "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"
} else {
"Offline"
}
}
}
if !current_status.models.is_empty() {
div { class: "sidebar-model-list",
for model in current_status.models.iter() {
span { class: "sidebar-model-tag", "{model}" }
}
}
}
}
// -- Trending Topics Section --
if !trending.is_empty() {
div { class: "sidebar-section",
h4 { class: "sidebar-section-title", "Trending" }
for topic in trending.iter() {
{
let t = topic.clone();
rsx! {
button {
class: "sidebar-topic-link",
onclick: move |_| on_topic_click.call(t.clone()),
"{topic}"
}
}
}
}
}
}
// -- Recent Searches Section --
if !recent_searches.is_empty() {
div { class: "sidebar-section",
h4 { class: "sidebar-section-title", "Recent Searches" }
for search in recent_searches.iter() {
{
let s = search.clone();
rsx! {
button {
class: "sidebar-topic-link",
onclick: move |_| on_topic_click.call(s.clone()),
"{search}"
}
}
}
}
}
}
}
}
}

View File

@@ -1,6 +1,8 @@
mod app_shell;
mod article_detail;
mod card;
mod chat_bubble;
mod dashboard_sidebar;
mod file_row;
mod login;
mod member_row;
@@ -12,8 +14,10 @@ pub mod sub_nav;
mod tool_card;
pub use app_shell::*;
pub use article_detail::*;
pub use card::*;
pub use chat_bubble::*;
pub use dashboard_sidebar::*;
pub use file_row::*;
pub use login::*;
pub use member_row::*;

View File

@@ -1,40 +1,67 @@
use crate::models::{NewsCard as NewsCardModel, NewsCategory};
use crate::models::NewsCard as NewsCardModel;
use dioxus::prelude::*;
/// Renders a news feed card with title, source, category badge, and summary.
///
/// When a thumbnail URL is present but the image fails to load, the card
/// automatically switches to the centered no-thumbnail layout.
///
/// # Arguments
///
/// * `card` - The news card model data to render
/// * `on_click` - Event handler triggered when the card is clicked
/// * `selected` - Whether this card is currently selected (highlighted)
#[component]
pub fn NewsCardView(card: NewsCardModel) -> Element {
let badge_class = format!("news-badge news-badge--{}", card.category.css_class());
pub fn NewsCardView(
card: NewsCardModel,
on_click: EventHandler<NewsCardModel>,
#[props(default = false)] selected: bool,
) -> Element {
// Derive a CSS class from the category string (lowercase, hyphenated)
let css_suffix = card.category.to_lowercase().replace(' ', "-");
let badge_class = format!("news-badge news-badge--{css_suffix}");
// Track whether the thumbnail loaded successfully.
// Starts as true if a URL is provided; set to false on image error.
let has_thumb_url = card.thumbnail_url.is_some();
let mut thumb_ok = use_signal(|| has_thumb_url);
let show_thumb = has_thumb_url && *thumb_ok.read();
let selected_cls = if selected { " news-card--selected" } else { "" };
let thumb_cls = if show_thumb {
""
} else {
" news-card--no-thumb"
};
let card_class = format!("news-card{selected_cls}{thumb_cls}");
// Clone the card for the click handler closure
let card_for_click = card.clone();
rsx! {
article { class: "news-card",
article {
class: "{card_class}",
onclick: move |_| on_click.call(card_for_click.clone()),
if let Some(ref thumb) = card.thumbnail_url {
div { class: "news-card-thumb",
img {
src: "{thumb}",
alt: "{card.title}",
loading: "lazy",
if *thumb_ok.read() {
div { class: "news-card-thumb",
img {
src: "{thumb}",
alt: "",
loading: "lazy",
// Hide the thumbnail container if the image fails to load
onerror: move |_| thumb_ok.set(false),
}
}
}
}
div { class: "news-card-body",
div { class: "news-card-meta",
span { class: "{badge_class}", "{card.category.label()}" }
span { class: "{badge_class}", "{card.category}" }
span { class: "news-card-source", "{card.source}" }
span { class: "news-card-date", "{card.published_at}" }
}
h3 { class: "news-card-title",
a {
href: "{card.url}",
target: "_blank",
rel: "noopener",
"{card.title}"
}
}
h3 { class: "news-card-title", "{card.title}" }
p { class: "news-card-summary", "{card.summary}" }
}
}
@@ -48,7 +75,12 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "Llama 4 Released with 1M Context Window".into(),
source: "Meta AI Blog".into(),
summary: "Meta releases Llama 4 with a 1 million token context window.".into(),
category: NewsCategory::Llm,
content: "Meta has officially released Llama 4, their latest \
open-weight large language model featuring a groundbreaking \
1 million token context window. This represents a major \
leap in context length capabilities."
.into(),
category: "AI".into(),
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-18".into(),
@@ -57,7 +89,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "EU AI Act Enforcement Begins".into(),
source: "TechCrunch".into(),
summary: "The EU AI Act enters its enforcement phase across member states.".into(),
category: NewsCategory::Privacy,
content: "The EU AI Act has officially entered its enforcement \
phase. Member states are now required to comply with the \
comprehensive regulatory framework governing AI systems."
.into(),
category: "Privacy".into(),
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-17".into(),
@@ -66,7 +102,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "LangChain v0.4 Introduces Native MCP Support".into(),
source: "LangChain Blog".into(),
summary: "New version adds first-class MCP server integration.".into(),
category: NewsCategory::Agents,
content: "LangChain v0.4 introduces native Model Context Protocol \
support, enabling seamless integration with MCP servers for \
tool use and context management in agent workflows."
.into(),
category: "Technology".into(),
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-16".into(),
@@ -75,7 +115,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "Ollama Adds Multi-GPU Scheduling".into(),
source: "Ollama".into(),
summary: "Run large models across multiple GPUs with automatic sharding.".into(),
category: NewsCategory::Infrastructure,
content: "Ollama now supports multi-GPU scheduling with automatic \
model sharding. Users can run models across multiple GPUs \
for improved inference performance."
.into(),
category: "Infrastructure".into(),
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-15".into(),
@@ -84,7 +128,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "Mistral Open Sources Codestral 2".into(),
source: "Mistral AI".into(),
summary: "Codestral 2 achieves state-of-the-art on HumanEval benchmarks.".into(),
category: NewsCategory::OpenSource,
content: "Mistral AI has open-sourced Codestral 2, a code \
generation model that achieves state-of-the-art results \
on HumanEval and other coding benchmarks."
.into(),
category: "Open Source".into(),
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-14".into(),
@@ -93,7 +141,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "NVIDIA Releases NeMo 3.0 Framework".into(),
source: "NVIDIA Developer".into(),
summary: "Updated framework simplifies enterprise LLM fine-tuning.".into(),
category: NewsCategory::Infrastructure,
content: "NVIDIA has released NeMo 3.0, an updated framework \
that simplifies enterprise LLM fine-tuning with improved \
distributed training capabilities."
.into(),
category: "Infrastructure".into(),
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-13".into(),
@@ -102,7 +154,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "Anthropic Claude 4 Sets New Reasoning Records".into(),
source: "Anthropic".into(),
summary: "Claude 4 achieves top scores across major reasoning benchmarks.".into(),
category: NewsCategory::Llm,
content: "Anthropic's Claude 4 has set new records across major \
reasoning benchmarks, demonstrating significant improvements \
in mathematical and logical reasoning capabilities."
.into(),
category: "AI".into(),
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-12".into(),
@@ -111,7 +167,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "CrewAI Raises $52M for Agent Orchestration".into(),
source: "VentureBeat".into(),
summary: "Series B funding to expand multi-agent orchestration platform.".into(),
category: NewsCategory::Agents,
content: "CrewAI has raised $52M in Series B funding to expand \
its multi-agent orchestration platform, enabling teams \
to build and deploy complex AI agent workflows."
.into(),
category: "Technology".into(),
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-11".into(),
@@ -120,7 +180,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "DeepSeek V4 Released Under Apache 2.0".into(),
source: "DeepSeek".into(),
summary: "Latest open-weight model competes with proprietary offerings.".into(),
category: NewsCategory::OpenSource,
content: "DeepSeek has released V4 under the Apache 2.0 license, \
an open-weight model that competes with proprietary \
offerings in both performance and efficiency."
.into(),
category: "Open Source".into(),
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-10".into(),
@@ -129,7 +193,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "GDPR Fines for AI Training Data Reach Record High".into(),
source: "Reuters".into(),
summary: "European regulators issue largest penalties yet for AI data misuse.".into(),
category: NewsCategory::Privacy,
content: "European regulators have issued record-high GDPR fines \
for AI training data misuse, signaling stricter enforcement \
of data protection laws in the AI sector."
.into(),
category: "Privacy".into(),
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-09".into(),