Files
certifai/src/components/article_detail.rs
Sharang Parnerkar d2ed7741a8 feat(dashboard): add sidebar with Ollama status, trending topics, and article detail panel
Integrate SearXNG news search, Ollama-powered article summarization with
follow-up chat, and a dashboard sidebar showing LLM status, trending
keywords, and recent search history. Sidebar yields to a split-view
article detail panel when a card is selected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:16:15 +01:00

159 lines
6.7 KiB
Rust

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"
}
}
}
}
}
}
}
}