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 <span> 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
This commit is contained in:
Generated
+1
@@ -496,6 +496,7 @@ dependencies = [
|
||||
"leptos 0.7.8",
|
||||
"leptos-use",
|
||||
"lucide-leptos",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
|
||||
@@ -51,3 +51,4 @@ serde_json = "1"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
wasm-logger = "0.2"
|
||||
console_error_panic_hook = "0.1"
|
||||
regex = "1"
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
// ── 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
|
||||
<div style="flex: 1; overflow: auto; padding: 1.5rem;">
|
||||
{move || match ui.active_tab.get() {
|
||||
Tab::Messages => view! { <div>"Messages Panel"</div> }.into_view(),
|
||||
Tab::Messages => view! { <MessagesPanel /> }.into_view(),
|
||||
Tab::Live => view! { <div>"Live Panel"</div> }.into_view(),
|
||||
Tab::Dashboard => view! { <div>"Dashboard Panel"</div> }.into_view(),
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,75 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::MessageRecord;
|
||||
|
||||
#[component]
|
||||
pub fn ImageGrid() -> impl IntoView {
|
||||
view! {
|
||||
<div>"Image Grid (placeholder)"</div>
|
||||
pub fn ImageGrid(
|
||||
messages: Vec<MessageRecord>,
|
||||
) -> 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! {
|
||||
<div class="flex items-center justify-center h-32 text-secondary italic">
|
||||
"No images found"
|
||||
</div>
|
||||
}.into_any();
|
||||
}
|
||||
|
||||
view! {
|
||||
<div class="image-grid">
|
||||
{urls.into_iter().map(|url| {
|
||||
let url_clone = url.clone();
|
||||
view! {
|
||||
<a href=url_clone target="_blank" class="image-grid-item">
|
||||
<img src=url alt="attachment" loading="lazy" />
|
||||
</a>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
+359
-2
@@ -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<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"<(a)?:([a-zA-Z0-9_]+):(\d+)>").unwrap())
|
||||
}
|
||||
|
||||
fn render_emojis(content: &str) -> Vec<AnyView> {
|
||||
let re = custom_emoji_regex();
|
||||
let mut parts: Vec<AnyView> = 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! { <span>{text}</span> }.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! {
|
||||
<img src=url alt=name class="custom-emoji" title=title loading="lazy" />
|
||||
}.into_any());
|
||||
last = m.end();
|
||||
}
|
||||
if last < content_owned.len() {
|
||||
let text = content_owned[last..].to_string();
|
||||
parts.push(view! { <span>{text}</span> }.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<String>>) -> Vec<String> {
|
||||
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! { <svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10 15.586L6.707 12.293a1 1 0 00-1.414 1.414l4 4a1 1 0 001.414 0l8-8a1 1 0 10-1.414-1.414L10 15.586z"></path></svg> }).into_any()),
|
||||
AiStatus::Flagged => ("status-badge-flagged", Some(view! { <svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg> }).into_any()),
|
||||
AiStatus::Error => ("status-badge-error", Some(view! { <svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg> }).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! {
|
||||
<div>"Message Card (placeholder)"</div>
|
||||
<span class=format!("status-badge {}", cl)>
|
||||
{icon_svg}
|
||||
{format!("{:?}", status)}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MessageRow ───────────────────────────────────────────
|
||||
#[component]
|
||||
pub fn MessageRow(
|
||||
message: MessageRecord,
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> 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::<Vec<_>>().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! {
|
||||
<div class="message-row">
|
||||
{/* Header */}
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span class="message-timestamp" title=time_ago(message.created_at)>
|
||||
{fmt_time(message.created_at)}
|
||||
</span>
|
||||
{message.edited_at.is_some().then(|| view! {
|
||||
<span class="flex items-center gap-0.5 text-xs text-secondary">
|
||||
"✎ edited"
|
||||
</span>
|
||||
})}
|
||||
{message.deleted_at.is_some().then(|| view! {
|
||||
<span class="flex items-center gap-0.5 text-xs text-destructive">
|
||||
"🗑 deleted"
|
||||
</span>
|
||||
})}
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<StatusBadgeInline status=ai_st.clone() />
|
||||
{message.ai_severity.as_ref().filter(|s| **s != AiSeverity::None).map(|sev| view! {
|
||||
<span class=format!("badge text-xs {}", severity_class(sev))>{format!("{:?}", sev)}</span>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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! {
|
||||
<p class=format!("whitespace-pre-wrap break-words text-sm leading-6 {}", cls)>
|
||||
{rendered.into_iter().collect::<Vec<_>>()}
|
||||
</p>
|
||||
}
|
||||
})}
|
||||
|
||||
{/* Stickers */}
|
||||
{(!stickers.is_empty()).then(|| view! {
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{stickers.iter().map(|s| {
|
||||
let url = s.url.clone();
|
||||
let name = s.name.clone().unwrap_or_default();
|
||||
view! {
|
||||
<div>
|
||||
{url.as_ref().map(|u| view! {
|
||||
<img src=u alt=&name class="h-12 w-12 rounded-lg border border-border object-contain bg-surface/50" loading="lazy" />
|
||||
}).unwrap_or_else(|| view! {
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-surface/50">
|
||||
"😊"
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
})}
|
||||
|
||||
{/* Images */}
|
||||
{(!imgs.is_empty()).then(|| view! {
|
||||
<div class="flex gap-2 overflow-x-auto">
|
||||
{imgs.iter().take(4).map(|a| view! {
|
||||
<a href=&a.url target="_blank" class="shrink-0 overflow-hidden rounded-lg border border-border">
|
||||
<img src=&a.url alt=&a.name class="h-16 w-16 object-cover hover:scale-105 transition-transform" loading="lazy" />
|
||||
</a>
|
||||
}).collect::<Vec<_>>()}
|
||||
{(imgs.len() > 4).then(|| view! {
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-lg border border-border bg-surface text-xs text-secondary">
|
||||
<span>{"+"} {imgs.len() - 4}</span> <span class="ml-0.5">"🖼"</span>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
|
||||
{/* Videos */}
|
||||
{(!vids.is_empty()).then(|| view! {
|
||||
<div class="flex gap-2 overflow-x-auto">
|
||||
{vids.iter().take(4).map(|a| view! {
|
||||
<video src=&a.url controls class="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-black" preload="metadata"></video>
|
||||
}).collect::<Vec<_>>()}
|
||||
{(vids.len() > 4).then(|| view! {
|
||||
<div class="flex h-28 w-16 items-center justify-center rounded-lg border border-border bg-surface text-xs text-secondary">
|
||||
<span>{"+"} {vids.len() - 4}</span> <span class="ml-0.5">"▶"</span>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
|
||||
{/* Categories */}
|
||||
{(!cats.is_empty()).then(|| view! {
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{cats.iter().map(|c| view! {
|
||||
<span class="badge badge-secondary text-xs">{c.clone()}</span>
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
})}
|
||||
|
||||
{/* 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! {
|
||||
<div class=format!("rounded-lg px-3 py-2 {}", border)>
|
||||
<div class="flex items-start gap-2 text-xs">
|
||||
<span class="mt-0.5 shrink-0">{icon}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block font-medium mb-1">{&analysis_summary}</span>
|
||||
<div class="text-xs leading-relaxed whitespace-pre-wrap">{analysis}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{/* Error */}
|
||||
{message.ai_error.as_ref().map(|e| view! {
|
||||
<div class="rounded-lg bg-warning/5 px-3 py-2 text-xs text-warning">
|
||||
<span>"AI error: "{e}</span>
|
||||
</div>
|
||||
})}
|
||||
|
||||
{/* Re-analyze */}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class=format!("btn btn-sm {}", if ai_st == AiStatus::Error { "btn-destructive" } else { "btn-outline" })
|
||||
on:click=on_click_re
|
||||
disabled=ai_st == AiStatus::Processing
|
||||
>
|
||||
<svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path></svg>
|
||||
" Re-analyze"
|
||||
</button>
|
||||
{(ai_st == AiStatus::Error).then(|| view! {
|
||||
<span class="text-xs text-secondary/70">"Click to retry"</span>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MessageCard ──────────────────────────────────────────
|
||||
#[component]
|
||||
pub fn MessageCard(
|
||||
messages: Vec<MessageRecord>,
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> 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! {
|
||||
<article class=format!("message-card shadow-sm transition-all {}", card_cls)>
|
||||
<div class="flex gap-3 p-4">
|
||||
<img src=avatar alt="" class="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-baseline gap-2 mb-2">
|
||||
<span class="font-semibold text-sm">{&first.username}</span>
|
||||
{loc_label.as_ref().map(|l| view! {
|
||||
<span class="flex items-center gap-1 text-xs text-secondary/50 bg-surface/50 px-1.5 py-0.5 rounded-full">
|
||||
"#" " " {l}
|
||||
</span>
|
||||
})}
|
||||
<span class="text-xs text-secondary/60">
|
||||
{time_ago(first.created_at)}
|
||||
{has_multi.then(|| format!(" · {} msgs", messages.len()))}
|
||||
</span>
|
||||
</div>
|
||||
<div class=if has_multi { "space-y-2.5" } else { "" }>
|
||||
{messages.into_iter().enumerate().map(|(i, msg)| {
|
||||
let sep = has_multi && i > 0;
|
||||
view! {
|
||||
<div class=if sep { "pt-2.5 border-t border-border/30" } else { "" }>
|
||||
<MessageRow message=msg on_reanalyze=on_reanalyze.clone() />
|
||||
</div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Skeleton ─────────────────────────────────────────────
|
||||
#[component]
|
||||
pub fn MessageCardSkeleton() -> impl IntoView {
|
||||
view! {
|
||||
<article class="message-card">
|
||||
<div class="flex gap-3 p-4">
|
||||
<div class="skeleton skeleton-circular" style="width:40px;height:40px"></div>
|
||||
<div class="min-w-0 flex-1 space-y-3">
|
||||
<div class="skeleton" style="height:20px;width:192px"></div>
|
||||
<div class="skeleton" style="height:16px;width:100%"></div>
|
||||
<div class="skeleton" style="height:16px;width:75%"></div>
|
||||
<div class="flex gap-2">
|
||||
<div class="skeleton" style="height:24px;width:64px;border-radius:9999px"></div>
|
||||
<div class="skeleton" style="height:24px;width:80px;border-radius:9999px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
+121
-2
@@ -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<MessageRecord>) -> Vec<Vec<MessageRecord>> {
|
||||
let mut groups: Vec<Vec<MessageRecord>> = 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<MessageRecord>,
|
||||
#[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<Arc<dyn Fn() + Send + Sync + 'static>>,
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let sentinel_ref = create_node_ref::<html::Div>();
|
||||
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::<dyn Fn(Vec<JsValue>)>::new(move |entries: Vec<JsValue>| {
|
||||
for entry in entries {
|
||||
if let Some(entry) = entry.dyn_ref::<web_sys::IntersectionObserverEntry>() {
|
||||
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! {
|
||||
<div class="space-y-4">
|
||||
{std::iter::repeat_with(|| {
|
||||
use super::message_card::MessageCardSkeleton;
|
||||
view! { <MessageCardSkeleton /> }
|
||||
}).take(3).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any();
|
||||
}
|
||||
|
||||
if messages.is_empty() {
|
||||
return view! {
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">
|
||||
{if empty_text.is_empty() { "No messages" } else { empty_text }}
|
||||
</div>
|
||||
</div>
|
||||
}.into_any();
|
||||
}
|
||||
|
||||
let groups = group_messages(messages);
|
||||
let has_more_val = has_more;
|
||||
let loading_more_val = loading_more;
|
||||
|
||||
view! {
|
||||
<div>"Message Feed (placeholder)"</div>
|
||||
<div class="space-y-4">
|
||||
{groups.into_iter().map(|group| {
|
||||
let cb = on_reanalyze.clone();
|
||||
view! {
|
||||
<MessageCardGroup messages=group on_reanalyze=cb.clone() />
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
|
||||
{/* Infinite scroll sentinel */}
|
||||
{has_more_val.then(|| {
|
||||
view! {
|
||||
<div node_ref=sentinel_ref class="h-4">
|
||||
{loading_more_val.then(|| {
|
||||
use super::message_card::MessageCardSkeleton;
|
||||
view! { <MessageCardSkeleton /> }
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn MessageCardGroup(
|
||||
messages: Vec<MessageRecord>,
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
use super::message_card::MessageCard;
|
||||
view! {
|
||||
<MessageCard messages=messages on_reanalyze=on_reanalyze />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<MessageRecord>::new());
|
||||
|
||||
@@ -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<MessageRecord>>(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::<crate::ws::context::WsContext>();
|
||||
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::<crate::app::AppConfig>() {
|
||||
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! {
|
||||
<div>"Messages Panel (loading...)"</div>
|
||||
<div class="messages-panel">
|
||||
{/* Header card */}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Messages"</div>
|
||||
<p class="card-description">
|
||||
"Messages are automatically captured from all text channels. Real-time updates arrive via WebSocket."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats badges */}
|
||||
{(total() > 0).then(|| view! {
|
||||
<div class="message-stats">
|
||||
<span class="badge badge-outline text-xs">{total()} " total" {state.has_more.get().then(|| "+")}</span>
|
||||
<span class="badge badge-success text-xs">{clean()} " clean"</span>
|
||||
<span class="badge badge-primary text-xs">{flagged()} " flagged"</span>
|
||||
<span class="badge badge-warning text-xs">{error()} " error"</span>
|
||||
<span class="badge badge-outline text-xs">{pending()} " pending"</span>
|
||||
{(deleted() > 0).then(|| view! {
|
||||
<span class="badge badge-destructive text-xs">{deleted()} " deleted"</span>
|
||||
})}
|
||||
{(edited() > 0).then(|| view! {
|
||||
<span class="badge badge-outline text-xs">{edited()} " edited"</span>
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
|
||||
{/* Search + filters row */}
|
||||
<div class="search-row">
|
||||
<div class="relative flex-1" style="min-width:200px">
|
||||
{/* Search icon as SVG */}
|
||||
<svg class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.35-4.35"></path></svg>
|
||||
<input
|
||||
class="input"
|
||||
style="padding-left:2.25rem;border-radius:9999px"
|
||||
placeholder="Search message content..."
|
||||
prop:value=search_query
|
||||
on:input=move |ev| set_search_query.set(event_target_value(&ev))
|
||||
on:keydown=move |ev| {
|
||||
if ev.key() == "Enter" { handle_search(()); }
|
||||
}
|
||||
disabled=is_searching.get()
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
on:click=handle_search
|
||||
disabled=is_searching.get() || search_query.get().trim().is_empty()
|
||||
>
|
||||
{move || if is_searching.get() { "Searching..." } else { "Search" }}
|
||||
</button>
|
||||
{show_search.get().then(|| view! {
|
||||
<button class="btn btn-outline btn-sm" on:click=clear_search>
|
||||
"✕ Clear"
|
||||
</button>
|
||||
})}
|
||||
{(error() > 0 && !show_search.get()).then(|| view! {
|
||||
<button
|
||||
class="btn btn-destructive btn-sm"
|
||||
on:click=handle_retry_all
|
||||
disabled=retrying_all.get()
|
||||
>
|
||||
{/* Rotate CCW icon as SVN */}
|
||||
<svg class=format!("mr-1.5 h-3.5 w-3.5{}", if retrying_all.get() { " animate-spin" } else { "" }) xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path></svg>
|
||||
{move || if retrying_all.get() { "Retrying...".to_string() } else { format!("Retry All Errors ({})", error()) }}
|
||||
</button>
|
||||
})}
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
{/* Filter icon as SVG since lucide-leptos Filter unavailable */}
|
||||
<svg class="h-4 w-4 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
|
||||
{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! {
|
||||
<button class=cls on:click=move |_| set_filter(f_ptr) >{*f}</button>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search results count */}
|
||||
{show_search.get().then(|| {
|
||||
let n = search_results.get().len();
|
||||
view! {
|
||||
<div class="text-sm text-secondary">
|
||||
"Found " {n} " result" {if n != 1 { "s" } else { "" }}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{/* View tabs + content */}
|
||||
<div class="tabs">
|
||||
<div class="tab-list">
|
||||
<button
|
||||
class="tab-trigger"
|
||||
class:active=move || view_tab.get() == ViewTab::All
|
||||
on:click=move |_| view_tab.set(ViewTab::All)
|
||||
aria-selected=move || if view_tab.get() == ViewTab::All { "true" } else { "false" }
|
||||
>
|
||||
{move || {
|
||||
let label = if show_search.get() { "Search" } else { "All" };
|
||||
format!("{} ({})", label, filtered_messages.with(|m| m.len()))
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
class="tab-trigger"
|
||||
class:active=move || view_tab.get() == ViewTab::Images
|
||||
on:click=move |_| view_tab.set(ViewTab::Images)
|
||||
aria-selected=move || if view_tab.get() == ViewTab::Images { "true" } else { "false" }
|
||||
>
|
||||
"Images"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" style:display=move || if view_tab.get() == ViewTab::All { "block" } else { "none" }>
|
||||
<MessageFeed
|
||||
messages=filtered_messages.get()
|
||||
empty_text=if show_search.get() { "No messages found matching your search." } else { "No captures yet." }
|
||||
loading=state.loading.get()
|
||||
has_more=if show_search.get() { false } else { state.has_more.get() }
|
||||
loading_more=state.loading_more.get()
|
||||
on_load_more={
|
||||
let cb = state.load_more.clone();
|
||||
Some(Arc::new(move || cb()) as Arc<dyn Fn() + Send + Sync + 'static>)
|
||||
}
|
||||
on_reanalyze=state.reanalyze.clone()
|
||||
/>
|
||||
</div>
|
||||
<div class="tab-content" style:display=move || if view_tab.get() == ViewTab::Images { "block" } else { "none" }>
|
||||
<ImageGrid messages=filtered_messages.get() />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user