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, #[props(default = false)] is_summarizing: bool, chat_messages: Vec, #[props(default = false)] is_chatting: bool, on_chat_send: EventHandler, ) -> 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" } } } } } } } }