use leptos::prelude::*; use shared_types::message::{AiStatus, MessageRecord}; use std::sync::Arc; use wasm_bindgen_futures::spawn_local; pub mod components; pub mod hooks; use components::message_feed::MessageFeed; use components::image_grid::ImageGrid; use hooks::use_messages::{merge_messages, use_messages}; type AiFilter = &'static str; const FILTERS: &[AiFilter] = &["all", "analyzed", "clean", "flagged", "error", "pending"]; #[derive(Clone, PartialEq)] enum ViewTab { All, Images } #[component] pub fn MessagesPanel() -> impl IntoView { let state = use_messages(); let (search_query, set_search_query) = create_signal(String::new()); let (search_results, set_search_results) = create_signal::>(Vec::new()); let (show_search, set_show_search) = create_signal(false); let (is_searching, set_is_searching) = create_signal(false); let ai_filter = RwSignal::new("analyzed".to_string()); let view_tab = RwSignal::new(ViewTab::All); let (retrying_all, set_retrying_all) = create_signal(false); // Stats derived from filtered messages let stats = create_memo(move |_| { let base = if show_search.get() { search_results.get() } else { state.messages.get() }; let total = base.len(); let clean = base.iter().filter(|m| m.ai_status == Some(AiStatus::Clean)).count(); let flagged = base.iter().filter(|m| m.ai_status == Some(AiStatus::Flagged)).count(); let error = base.iter().filter(|m| m.ai_status == Some(AiStatus::Error)).count(); let pending = base.iter().filter(|m| m.ai_status.is_none() || m.ai_status == Some(AiStatus::Pending)).count(); let deleted = base.iter().filter(|m| m.deleted_at.is_some()).count(); let edited = base.iter().filter(|m| m.edited_at.is_some()).count(); (total, clean, flagged, error, pending, deleted, edited) }); // Filter messages based on active filter let filtered_messages = create_memo(move |_| { let base = if show_search.get() { search_results.get() } else { state.messages.get() }; let filter = ai_filter.get(); if filter == "all" { return base; } base.into_iter().filter(|m| { let status = m.ai_status.clone().unwrap_or(AiStatus::Pending); if filter == "analyzed" { return status != AiStatus::Pending; } if filter == "pending" { return status == AiStatus::Pending; } format!("{:?}", status).to_lowercase() == filter }).collect() }); // Search handler - takes any event type and triggers the search let do_search = { let q = search_query; move || { let query = q.get(); if query.trim().is_empty() { set_show_search.set(false); set_search_results.set(Vec::new()); return; } set_is_searching.set(true); let q_clone = query.trim().to_string(); spawn_local(async move { match crate::api::messages::search_messages(&q_clone, Some(50)).await { Ok(results) => { set_search_results.set(results); set_show_search.set(true); } Err(_) => { set_search_results.set(Vec::new()); } } set_is_searching.set(false); }); } }; // Separate closures for different event types so on:click/on:keydown type-check let handle_search_click = move |_: web_sys::MouseEvent| do_search(); let handle_search_keydown = move |_: web_sys::KeyboardEvent| do_search(); // Clear search let clear_search = move |_| { set_show_search.set(false); set_search_results.set(Vec::new()); set_search_query.set(String::new()); }; // Reanalyze all errors let handle_retry_all = move |_| { set_retrying_all.set(true); let cb = state.reanalyze_all_errors.clone(); spawn_local(async move { cb(); set_retrying_all.set(false); }); }; // Filter chip click let set_filter = { let af = ai_filter; move |f: &'static str| af.set(f.to_string()) }; // WS event handlers (wire once on mount) let ws = use_context::(); if let Some(ref ws) = ws { // Subscribe to real-time message events { let msgs = state.messages; *ws.on_message_created.borrow_mut() = Some(Box::new(move |msg| { let current = msgs.get(); msgs.set(merge_messages(¤t, &[msg])); })); } { let msgs = state.messages; *ws.on_message_updated.borrow_mut() = Some(Box::new(move |msg| { let current = msgs.get(); msgs.set(merge_messages(¤t, &[msg])); })); } { let msgs = state.messages; *ws.on_message_deleted.borrow_mut() = Some(Box::new(move |id| { let current = msgs.get(); msgs.set(current.into_iter().filter(|m| m.id != id).collect()); })); } { let msgs = state.messages; *ws.on_message_analyzed.borrow_mut() = Some(Box::new(move |msg| { let current = msgs.get(); msgs.set(merge_messages(¤t, &[msg])); })); } } // Fetch messages on mount if guild is configured create_effect(move |_| { if let Some(config) = use_context::() { if let Some(ref guild_id) = config.monitor_guild_id { (state.fetch_messages)(guild_id.clone()); } } }); // ─── View ──────────────────────────────────────────────── let get_stats = move || stats.get(); let (total, clean, flagged, error, pending, deleted, edited) = ( move || get_stats().0, move || get_stats().1, move || get_stats().2, move || get_stats().3, move || get_stats().4, move || get_stats().5, move || get_stats().6, ); view! {
{/* Header card */}
"Messages"

"Messages are automatically captured from all text channels. Real-time updates arrive via WebSocket."

{/* Stats badges */} {(total() > 0).then(|| view! {
{total()} " total" {state.has_more.get().then(|| "+")} {clean()} " clean" {flagged()} " flagged" {error()} " error" {pending()} " pending" {(deleted() > 0).then(|| view! { {deleted()} " deleted" })} {(edited() > 0).then(|| view! { {edited()} " edited" })}
})} {/* Search + filters row */}
{/* Search icon as SVG */}
{show_search.get().then(|| view! { })} {(error() > 0 && !show_search.get()).then(|| view! { })}
{/* Filter icon as SVG since lucide-leptos Filter unavailable */} {FILTERS.iter().map(|f| { let active = ai_filter.get() == *f; let cls = if active { "filter-chip active" } else { "filter-chip" }; let f_ptr: &'static str = f; view! { } }).collect::>()}
{/* Search results count */} {show_search.get().then(|| { let n = search_results.get().len(); view! {
"Found " {n} " result" {if n != 1 { "s" } else { "" }}
} })} {/* View tabs + content */}
{ let load_more_cb = state.load_more.clone(); let empty_text: &'static str = if show_search.get() { "No messages found matching your search." } else { "No captures yet." }; let has_more = if show_search.get() { false } else { state.has_more.get() }; let on_load_more_clone: Arc = Arc::new(move || load_more_cb()); view! { } }
} }