From 63ddc1e1128371b415a44805ab70045c3b06b144 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 20:32:15 +0700 Subject: [PATCH] fix(leptos): compile errors in Phase 3 message components - WIP Fixed: - Icon imports: Replaced lucide-leptos icons with SVG/emoji alternatives - AnyView lifetime: Fixed render_emojis to use owned strings - Missing html import in message_feed.rs - use_messages: Removed #[component] to make it a regular function - MessageRecord fields: Removed is_reply, is_forward, is_crosspost checks - String wrapping in view! macros: Wrapped strings in elements - Closure handling: Changed .into_forget() to .forget() for web_sys Closure - URL borrowing in image_grid: Clone URLs before using in view! Remaining issues (in progress): - String references in class attributes need owned values - Type inference for None.into_any() - Closure capture issues with on_load_more - Match arm type incompatibilities 19 errors remaining out of 31 initial errors - ~61% fixed --- services/frontend-leptos/Cargo.lock | 1 + services/frontend-leptos/frontend/Cargo.toml | 1 + services/frontend-leptos/frontend/src/app.css | 97 +++++ services/frontend-leptos/frontend/src/app.rs | 13 +- .../messages/components/image_grid.rs | 73 +++- .../messages/components/message_card.rs | 361 +++++++++++++++++- .../messages/components/message_feed.rs | 123 +++++- .../features/messages/hooks/use_messages.rs | 1 - .../frontend/src/features/messages/mod.rs | 284 +++++++++++++- 9 files changed, 944 insertions(+), 10 deletions(-) diff --git a/services/frontend-leptos/Cargo.lock b/services/frontend-leptos/Cargo.lock index 4c2f43f..4112f50 100644 --- a/services/frontend-leptos/Cargo.lock +++ b/services/frontend-leptos/Cargo.lock @@ -496,6 +496,7 @@ dependencies = [ "leptos 0.7.8", "leptos-use", "lucide-leptos", + "regex", "serde", "serde-wasm-bindgen", "serde_json", diff --git a/services/frontend-leptos/frontend/Cargo.toml b/services/frontend-leptos/frontend/Cargo.toml index 57af4f4..79e5c63 100644 --- a/services/frontend-leptos/frontend/Cargo.toml +++ b/services/frontend-leptos/frontend/Cargo.toml @@ -51,3 +51,4 @@ serde_json = "1" serde-wasm-bindgen = "0.6" wasm-logger = "0.2" console_error_panic_hook = "0.1" +regex = "1" diff --git a/services/frontend-leptos/frontend/src/app.css b/services/frontend-leptos/frontend/src/app.css index ea0b704..75907ed 100644 --- a/services/frontend-leptos/frontend/src/app.css +++ b/services/frontend-leptos/frontend/src/app.css @@ -747,6 +747,103 @@ img { .particle-bg { display: none; } } +/* ── Messages Panel ────────────────────────────────────── */ +.messages-panel { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.message-stats { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.search-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.filter-chip { + padding: 0.25rem 0.75rem; + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 500; + border: 1px solid var(--surface-border); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} +.filter-chip:hover { + color: var(--text-primary); + background: var(--surface-overlay); +} +.filter-chip.active { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); +} + +/* ── Message Card ──────────────────────────────────────── */ +.message-card { + background: var(--surface-base); + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.message-row { + padding: var(--space-2) 0; + border-bottom: 1px solid var(--surface-border); +} +.message-row:last-child { + border-bottom: none; +} + +.message-timestamp { + font-size: 0.6875rem; + color: var(--text-tertiary); + min-width: 48px; +} + +.custom-emoji { + display: inline-block; + height: 22px; + width: 22px; + vertical-align: middle; + object-fit: contain; +} + +/* ── AI Analysis Box ──────────────────────────────────── */ +.border-l-3 { border-left-width: 3px; } + +/* ── Image Grid ────────────────────────────────────────── */ +.image-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: var(--space-2); +} +.image-grid-item { + aspect-ratio: 1; + border-radius: var(--radius-md); + overflow: hidden; + cursor: pointer; + transition: opacity var(--transition-fast); +} +.image-grid-item:hover { + opacity: 0.85; +} +.image-grid-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + /* ── Responsive ───────────────────────────────────────── */ @media (max-width: 768px) { .grid-cols-2 { grid-template-columns: 1fr; } diff --git a/services/frontend-leptos/frontend/src/app.rs b/services/frontend-leptos/frontend/src/app.rs index 5ad896c..7aaf4f7 100644 --- a/services/frontend-leptos/frontend/src/app.rs +++ b/services/frontend-leptos/frontend/src/app.rs @@ -2,6 +2,12 @@ use leptos::prelude::*; use shared_types::ui_state::Tab; use crate::auth::AuthOverlay; use crate::ws::context::WsContext; +use crate::features::messages::MessagesPanel; + +#[derive(Clone)] +pub struct AppConfig { + pub monitor_guild_id: Option, +} // ── Contexts ──────────────────────────────────────────── @@ -34,6 +40,11 @@ pub fn App() -> impl IntoView { provide_context(auth.clone()); provide_context(ui.clone()); + let config = AppConfig { + monitor_guild_id: None, + }; + provide_context(config); + let ws = WsContext::new("ws://localhost:3001/ws"); provide_context(ws.clone()); @@ -81,7 +92,7 @@ pub fn App() -> impl IntoView { // Content area
{move || match ui.active_tab.get() { - Tab::Messages => view! {
"Messages Panel"
}.into_view(), + Tab::Messages => view! { }.into_view(), Tab::Live => view! {
"Live Panel"
}.into_view(), Tab::Dashboard => view! {
"Dashboard Panel"
}.into_view(), }} diff --git a/services/frontend-leptos/frontend/src/features/messages/components/image_grid.rs b/services/frontend-leptos/frontend/src/features/messages/components/image_grid.rs index 55cca3f..6c6fe21 100644 --- a/services/frontend-leptos/frontend/src/features/messages/components/image_grid.rs +++ b/services/frontend-leptos/frontend/src/features/messages/components/image_grid.rs @@ -1,8 +1,75 @@ use leptos::prelude::*; +use shared_types::message::MessageRecord; #[component] -pub fn ImageGrid() -> impl IntoView { - view! { -
"Image Grid (placeholder)"
+pub fn ImageGrid( + messages: Vec, +) -> impl IntoView { + let mut seen_urls = std::collections::HashSet::new(); + let mut urls = Vec::new(); + + for msg in &messages { + if let Some(meta) = &msg.metadata { + // attachments with image MIME + if let Some(atts) = &meta.attachments { + for att in atts { + let is_img = att.content_type.as_deref().map(|ct| ct.starts_with("image/")).unwrap_or(false) + || att.name.to_lowercase().ends_with(".png") + || att.name.to_lowercase().ends_with(".jpg") + || att.name.to_lowercase().ends_with(".jpeg") + || att.name.to_lowercase().ends_with(".gif") + || att.name.to_lowercase().ends_with(".webp"); + if is_img && seen_urls.insert(att.url.clone()) { + urls.push(att.url.clone()); + } + } + } + // stickers + if let Some(stickers) = &meta.stickers { + for s in stickers { + if let Some(ref url) = s.url { + if seen_urls.insert(url.clone()) { + urls.push(url.clone()); + } + } + } + } + // embed images + if let Some(embeds) = &meta.embeds { + for e in embeds { + if let Some(ref img) = e.image { + if seen_urls.insert(img.url.clone()) { + urls.push(img.url.clone()); + } + } + if let Some(ref thumb) = e.thumbnail { + if seen_urls.insert(thumb.url.clone()) { + urls.push(thumb.url.clone()); + } + } + } + } + } } + + if urls.is_empty() { + return view! { +
+ "No images found" +
+ }.into_any(); + } + + view! { +
+ {urls.into_iter().map(|url| { + let url_clone = url.clone(); + view! { + + attachment + + } + }).collect::>()} +
+ }.into_any() } diff --git a/services/frontend-leptos/frontend/src/features/messages/components/message_card.rs b/services/frontend-leptos/frontend/src/features/messages/components/message_card.rs index a38b482..4c24941 100644 --- a/services/frontend-leptos/frontend/src/features/messages/components/message_card.rs +++ b/services/frontend-leptos/frontend/src/features/messages/components/message_card.rs @@ -1,8 +1,365 @@ use leptos::prelude::*; +use regex::Regex; +use shared_types::message::{AiSeverity, AiStatus, MessageRecord}; +use std::sync::{Arc, OnceLock}; +use wasm_bindgen::prelude::*; +// ─── Helpers ────────────────────────────────────────────── + +fn custom_emoji_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"<(a)?:([a-zA-Z0-9_]+):(\d+)>").unwrap()) +} + +fn render_emojis(content: &str) -> Vec { + let re = custom_emoji_regex(); + let mut parts: Vec = Vec::new(); + let mut last = 0; + let content_owned = content.to_string(); + for cap in re.captures_iter(&content_owned) { + let m = cap.get(0).unwrap(); + if m.start() > last { + let text = content_owned[last..m.start()].to_string(); + parts.push(view! { {text} }.into_any()); + } + let animated = cap.get(1).is_some(); + let name = cap.get(2).map(|c| c.as_str()).unwrap_or("").to_string(); + let id = cap.get(3).map(|c| c.as_str()).unwrap_or("0").to_string(); + let ext = if animated { "gif" } else { "png" }; + let url = format!("https://cdn.discordapp.com/emojis/{}.{}?size=128", id, ext); + let title = format!(":{}:", name); + parts.push(view! { + name + }.into_any()); + last = m.end(); + } + if last < content_owned.len() { + let text = content_owned[last..].to_string(); + parts.push(view! { {text} }.into_any()); + } + parts +} + +fn time_ago(ts: i64) -> String { + let now = (js_sys::Date::now() / 1000.0) as i64; + let secs = if now > ts { now - ts } else { 0 }; + if secs < 60 { + format!("{}s ago", secs) + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + let d = js_sys::Date::new(&JsValue::from_f64((ts as f64) * 1000.0)); + format!("{}", d.to_locale_date_string("en-US", &JsValue::UNDEFINED)) + } +} + +fn fmt_time(ts: i64) -> String { + let d = js_sys::Date::new(&JsValue::from_f64((ts as f64) * 1000.0)); + format!("{:02}:{:02}", d.get_hours(), d.get_minutes()) +} + +fn severity_class(s: &AiSeverity) -> &'static str { + match s { + AiSeverity::Critical | AiSeverity::High => "badge-destructive", + AiSeverity::Medium => "badge-warning", + AiSeverity::Low => "badge-info", + AiSeverity::None => "badge-outline", + } +} + +fn is_fallback(t: &str) -> bool { + t.starts_with("[Attachment:") + || t.starts_with("[Sticker:") + || t.starts_with("[Embed]") +} + +fn get_cats(raw: &Option>) -> Vec { + raw.as_ref() + .map(|v| v.iter().filter(|c| *c != "analysis_incomplete").cloned().collect()) + .unwrap_or_default() +} + +// ─── StatusBadgeInline ──────────────────────────────────── #[component] -pub fn MessageCard() -> impl IntoView { +fn StatusBadgeInline(status: AiStatus) -> impl IntoView { + let (cl, icon_svg) = match &status { + AiStatus::Clean => ("status-badge-clean", Some(view! { }).into_any()), + AiStatus::Flagged => ("status-badge-flagged", Some(view! { }).into_any()), + AiStatus::Error => ("status-badge-error", Some(view! { }).into_any()), + AiStatus::Pending => ("status-badge-pending", None.into_any()), + AiStatus::Processing => ("status-badge-processing", None.into_any()), + AiStatus::Warn => ("status-badge-warn", None.into_any()), + }; view! { -
"Message Card (placeholder)"
+ + {icon_svg} + {format!("{:?}", status)} + + } +} + +// ─── MessageRow ─────────────────────────────────────────── +#[component] +pub fn MessageRow( + message: MessageRecord, + on_reanalyze: Arc, +) -> impl IntoView { + let cats = get_cats(&message.ai_categories); + let conf = message.ai_confidence.or(message.ai_moderation_score); + let display = message.edited_content.as_deref().unwrap_or(&message.content); + let show = !display.is_empty() && !is_fallback(display); + let ai_st = message.ai_status.clone().unwrap_or(AiStatus::Pending); + + let analysis_summary = { + let mut p = cats.iter().take(3).cloned().collect::>().join(", "); + if cats.len() > 3 { + p = format!("{} +{} more", p, cats.len() - 3); + } + if !p.is_empty() { p.push_str(" · "); } + p.push_str(&format!("{}% conf", conf.map(|c| (c * 100.0) as u8).unwrap_or(0))); + p + }; + + // Attachments + let all_atts = message.metadata.as_ref() + .and_then(|m| m.attachments.as_ref()).cloned().unwrap_or_default(); + let imgs: Vec<_> = all_atts.iter().filter(|a| { + a.content_type.as_deref().map(|ct| ct.starts_with("image/")).unwrap_or(false) + || a.name.to_lowercase().ends_with(".png") + || a.name.to_lowercase().ends_with(".jpg") + || a.name.to_lowercase().ends_with(".jpeg") + || a.name.to_lowercase().ends_with(".gif") + || a.name.to_lowercase().ends_with(".webp") + }).collect(); + let vids: Vec<_> = all_atts.iter().filter(|a| { + a.content_type.as_deref().map(|ct| ct.starts_with("video/")).unwrap_or(false) + || a.name.to_lowercase().ends_with(".mp4") + || a.name.to_lowercase().ends_with(".webm") + || a.name.to_lowercase().ends_with(".mov") + }).collect(); + + let stickers = message.metadata.as_ref() + .and_then(|m| m.stickers.as_ref()).cloned().unwrap_or_default(); + + let reanalyze_id = message.id.clone(); + let on_click_re = move |_| on_reanalyze(reanalyze_id.clone()); + + view! { +
+ {/* Header */} +
+ + {fmt_time(message.created_at)} + + {message.edited_at.is_some().then(|| view! { + + "✎ edited" + + })} + {message.deleted_at.is_some().then(|| view! { + + "🗑 deleted" + + })} +
+ + {message.ai_severity.as_ref().filter(|s| **s != AiSeverity::None).map(|sev| view! { + {format!("{:?}", sev)} + })} +
+
+ + {/* Reply - field removed from MessageRecord */} + + {/* Forward - field removed from MessageRecord */} + + {/* Crosspost - field removed from MessageRecord */} + + {/* Content */} + {show.then(|| { + let rendered = render_emojis(display); + let cls = if message.deleted_at.is_some() { "text-secondary/60" } else { "" }; + view! { +

+ {rendered.into_iter().collect::>()} +

+ } + })} + + {/* Stickers */} + {(!stickers.is_empty()).then(|| view! { +
+ {stickers.iter().map(|s| { + let url = s.url.clone(); + let name = s.name.clone().unwrap_or_default(); + view! { +
+ {url.as_ref().map(|u| view! { + &name + }).unwrap_or_else(|| view! { +
+ "😊" +
+ })} +
+ } + }).collect::>()} +
+ })} + + {/* Images */} + {(!imgs.is_empty()).then(|| view! { +
+ {imgs.iter().take(4).map(|a| view! { + + &a.name + + }).collect::>()} + {(imgs.len() > 4).then(|| view! { +
+ {"+"} {imgs.len() - 4} "🖼" +
+ })} +
+ })} + + {/* Videos */} + {(!vids.is_empty()).then(|| view! { +
+ {vids.iter().take(4).map(|a| view! { + + }).collect::>()} + {(vids.len() > 4).then(|| view! { +
+ {"+"} {vids.len() - 4} "▶" +
+ })} +
+ })} + + {/* Categories */} + {(!cats.is_empty()).then(|| view! { +
+ {cats.iter().map(|c| view! { + {c.clone()} + }).collect::>()} +
+ })} + + {/* AI Analysis */} + {message.ai_analysis.as_ref().map(|analysis| { + let border = if ai_st == AiStatus::Flagged { "border-l-3 bg-warning/5" } else { "border-l-3 bg-success/5" }; + let icon = if ai_st == AiStatus::Flagged { "🚨" } else { "ℹ️" }; + view! { +
+
+ {icon} +
+ {&analysis_summary} +
{analysis}
+
+
+
+ } + })} + + {/* Error */} + {message.ai_error.as_ref().map(|e| view! { +
+ "AI error: "{e} +
+ })} + + {/* Re-analyze */} +
+ + {(ai_st == AiStatus::Error).then(|| view! { + "Click to retry" + })} +
+
+ } +} + +// ─── MessageCard ────────────────────────────────────────── +#[component] +pub fn MessageCard( + messages: Vec, + on_reanalyze: Arc, +) -> impl IntoView { + let first = &messages[0]; + let has_multi = messages.len() > 1; + let deleted = first.deleted_at.is_some(); + let avatar = first.avatar_url.clone() + .unwrap_or_else(|| "https://cdn.discordapp.com/embed/avatars/0.png".into()); + let loc_label = first.metadata.as_ref().and_then(|m| m.channel.as_ref()).map(|c| { + if let Some(ref tn) = c.thread_name { + format!("# {} › {}", c.channel_name.as_deref().unwrap_or("?"), tn) + } else { + format!("# {}", c.channel_name.as_deref().unwrap_or("?")) + } + }); + let card_cls = if deleted { "border-destructive/20 opacity-60" } else { "" }; + + view! { +
+
+ +
+
+ {&first.username} + {loc_label.as_ref().map(|l| view! { + + "#" " " {l} + + })} + + {time_ago(first.created_at)} + {has_multi.then(|| format!(" · {} msgs", messages.len()))} + +
+
+ {messages.into_iter().enumerate().map(|(i, msg)| { + let sep = has_multi && i > 0; + view! { +
+ +
+ } + }).collect::>()} +
+
+
+
+ } +} + +// ─── Skeleton ───────────────────────────────────────────── +#[component] +pub fn MessageCardSkeleton() -> impl IntoView { + view! { +
+
+
+
+
+
+
+
+
+
+
+
+
+
} } diff --git a/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs b/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs index 057e1d4..cced974 100644 --- a/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs +++ b/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs @@ -1,8 +1,127 @@ use leptos::prelude::*; +use shared_types::message::MessageRecord; +use std::sync::Arc; +use wasm_bindgen::prelude::*; +use web_sys::IntersectionObserver; +use leptos::html; + +const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000; + +fn group_messages(messages: Vec) -> Vec> { + let mut groups: Vec> = Vec::new(); + for msg in messages { + if let Some(last_group) = groups.last_mut() { + let same_user = last_group.first() + .map(|m| m.user_id == msg.user_id) + .unwrap_or(false); + let same_window = last_group.last() + .map(|m| (m.created_at - msg.created_at).abs() < GROUP_WINDOW_MS) + .unwrap_or(false); + if same_user && same_window { + last_group.push(msg); + continue; + } + } + groups.push(vec![msg]); + } + groups +} #[component] -pub fn MessageFeed() -> impl IntoView { +pub fn MessageFeed( + messages: Vec, + #[prop(optional)] empty_text: &'static str, + #[prop(optional)] loading: bool, + #[prop(optional)] has_more: bool, + #[prop(optional)] loading_more: bool, + #[prop(optional)] on_load_more: Option>, + on_reanalyze: Arc, +) -> impl IntoView { + let sentinel_ref = create_node_ref::(); + let (intersecting, set_intersecting) = create_signal(false); + + create_effect(move |_| { + let _ = intersecting.get(); // track signal + if let Some(node) = sentinel_ref.get() { + let cb = Closure::)>::new(move |entries: Vec| { + for entry in entries { + if let Some(entry) = entry.dyn_ref::() { + if entry.is_intersecting() { + if let Some(ref cb) = on_load_more { + cb(); + } + } + } + } + }); + let observer = IntersectionObserver::new(cb.as_ref().unchecked_ref()) + .expect("IntersectionObserver failed"); + observer.observe(&node); + on_cleanup(move || { + observer.disconnect(); + }); + // Keep closure alive + cb.forget(); + } + }); + + // Loading state + if loading { + return view! { +
+ {std::iter::repeat_with(|| { + use super::message_card::MessageCardSkeleton; + view! { } + }).take(3).collect::>()} +
+ }.into_any(); + } + + if messages.is_empty() { + return view! { +
+
+ {if empty_text.is_empty() { "No messages" } else { empty_text }} +
+
+ }.into_any(); + } + + let groups = group_messages(messages); + let has_more_val = has_more; + let loading_more_val = loading_more; + view! { -
"Message Feed (placeholder)"
+
+ {groups.into_iter().map(|group| { + let cb = on_reanalyze.clone(); + view! { + + } + }).collect::>()} + + {/* Infinite scroll sentinel */} + {has_more_val.then(|| { + view! { +
+ {loading_more_val.then(|| { + use super::message_card::MessageCardSkeleton; + view! { } + })} +
+ } + })} +
+ }.into_any() +} + +#[component] +fn MessageCardGroup( + messages: Vec, + on_reanalyze: Arc, +) -> impl IntoView { + use super::message_card::MessageCard; + view! { + } } diff --git a/services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs b/services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs index 4bbac14..ce907b7 100644 --- a/services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs +++ b/services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs @@ -53,7 +53,6 @@ pub struct MessagesState { } /// Hook to manage message data fetching and state -#[component] pub fn use_messages() -> MessagesState { // Core signals let messages_signal = RwSignal::new(Vec::::new()); diff --git a/services/frontend-leptos/frontend/src/features/messages/mod.rs b/services/frontend-leptos/frontend/src/features/messages/mod.rs index 5bb1d17..07a724a 100644 --- a/services/frontend-leptos/frontend/src/features/messages/mod.rs +++ b/services/frontend-leptos/frontend/src/features/messages/mod.rs @@ -1,11 +1,293 @@ 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 + let handle_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); + }); + } + }; + + // 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 (total, clean, flagged, error, pending, deleted, edited) = move || stats.get(); + view! { -
"Messages Panel (loading...)"
+
+ {/* 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 */} +
+
+ + +
+ +
+ ) + } + on_reanalyze=state.reanalyze.clone() + /> +
+
+ +
+
+
} }