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 0.7.8",
|
||||||
"leptos-use",
|
"leptos-use",
|
||||||
"lucide-leptos",
|
"lucide-leptos",
|
||||||
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde-wasm-bindgen",
|
"serde-wasm-bindgen",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -51,3 +51,4 @@ serde_json = "1"
|
|||||||
serde-wasm-bindgen = "0.6"
|
serde-wasm-bindgen = "0.6"
|
||||||
wasm-logger = "0.2"
|
wasm-logger = "0.2"
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
|
regex = "1"
|
||||||
|
|||||||
@@ -747,6 +747,103 @@ img {
|
|||||||
.particle-bg { display: none; }
|
.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 ───────────────────────────────────────── */
|
/* ── Responsive ───────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.grid-cols-2 { grid-template-columns: 1fr; }
|
.grid-cols-2 { grid-template-columns: 1fr; }
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ use leptos::prelude::*;
|
|||||||
use shared_types::ui_state::Tab;
|
use shared_types::ui_state::Tab;
|
||||||
use crate::auth::AuthOverlay;
|
use crate::auth::AuthOverlay;
|
||||||
use crate::ws::context::WsContext;
|
use crate::ws::context::WsContext;
|
||||||
|
use crate::features::messages::MessagesPanel;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub monitor_guild_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
// ── Contexts ────────────────────────────────────────────
|
// ── Contexts ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -34,6 +40,11 @@ pub fn App() -> impl IntoView {
|
|||||||
provide_context(auth.clone());
|
provide_context(auth.clone());
|
||||||
provide_context(ui.clone());
|
provide_context(ui.clone());
|
||||||
|
|
||||||
|
let config = AppConfig {
|
||||||
|
monitor_guild_id: None,
|
||||||
|
};
|
||||||
|
provide_context(config);
|
||||||
|
|
||||||
let ws = WsContext::new("ws://localhost:3001/ws");
|
let ws = WsContext::new("ws://localhost:3001/ws");
|
||||||
provide_context(ws.clone());
|
provide_context(ws.clone());
|
||||||
|
|
||||||
@@ -81,7 +92,7 @@ pub fn App() -> impl IntoView {
|
|||||||
// Content area
|
// Content area
|
||||||
<div style="flex: 1; overflow: auto; padding: 1.5rem;">
|
<div style="flex: 1; overflow: auto; padding: 1.5rem;">
|
||||||
{move || match ui.active_tab.get() {
|
{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::Live => view! { <div>"Live Panel"</div> }.into_view(),
|
||||||
Tab::Dashboard => view! { <div>"Dashboard Panel"</div> }.into_view(),
|
Tab::Dashboard => view! { <div>"Dashboard Panel"</div> }.into_view(),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,8 +1,75 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use shared_types::message::MessageRecord;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ImageGrid() -> impl IntoView {
|
pub fn ImageGrid(
|
||||||
view! {
|
messages: Vec<MessageRecord>,
|
||||||
<div>"Image Grid (placeholder)"</div>
|
) -> 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 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]
|
#[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! {
|
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 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]
|
#[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! {
|
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
|
/// Hook to manage message data fetching and state
|
||||||
#[component]
|
|
||||||
pub fn use_messages() -> MessagesState {
|
pub fn use_messages() -> MessagesState {
|
||||||
// Core signals
|
// Core signals
|
||||||
let messages_signal = RwSignal::new(Vec::<MessageRecord>::new());
|
let messages_signal = RwSignal::new(Vec::<MessageRecord>::new());
|
||||||
|
|||||||
@@ -1,11 +1,293 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use shared_types::message::{AiStatus, MessageRecord};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
|
||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod hooks;
|
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]
|
#[component]
|
||||||
pub fn MessagesPanel() -> impl IntoView {
|
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! {
|
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