feat: update components and hooks to use get_untracked for improved performance
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::{Request, RequestInit, RequestMode, Headers, Response};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{Headers, Request, RequestInit, RequestMode, Response};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
@@ -22,7 +22,9 @@ fn get_base_url() -> String {
|
||||
let location = window.location();
|
||||
let protocol = location.protocol().unwrap_or_else(|_| "http:".to_string());
|
||||
let protocol = protocol.trim_end_matches(':');
|
||||
let host = location.host().unwrap_or_else(|_| "localhost:3001".to_string());
|
||||
let host = location
|
||||
.host()
|
||||
.unwrap_or_else(|_| "localhost:3001".to_string());
|
||||
format!("{}://{}", protocol, host)
|
||||
} else {
|
||||
"http://localhost:3001".to_string()
|
||||
@@ -88,12 +90,10 @@ pub async fn request<T: DeserializeOwned>(
|
||||
|
||||
let status = response.status();
|
||||
if status >= 400 {
|
||||
let text = JsFuture::from(
|
||||
response.text().map_err(|_| ApiError {
|
||||
message: "Failed to read error body".to_string(),
|
||||
status_code: status,
|
||||
})?
|
||||
)
|
||||
let text = JsFuture::from(response.text().map_err(|_| ApiError {
|
||||
message: "Failed to read error body".to_string(),
|
||||
status_code: status,
|
||||
})?)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.as_string())
|
||||
@@ -105,12 +105,10 @@ pub async fn request<T: DeserializeOwned>(
|
||||
});
|
||||
}
|
||||
|
||||
let text = JsFuture::from(
|
||||
response.text().map_err(|_| ApiError {
|
||||
message: "Failed to read response body".to_string(),
|
||||
status_code: status,
|
||||
})?
|
||||
)
|
||||
let text = JsFuture::from(response.text().map_err(|_| ApiError {
|
||||
message: "Failed to read response body".to_string(),
|
||||
status_code: status,
|
||||
})?)
|
||||
.await
|
||||
.map_err(|_| ApiError {
|
||||
message: "Failed to await response".to_string(),
|
||||
@@ -123,7 +121,11 @@ pub async fn request<T: DeserializeOwned>(
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&text).map_err(|e| ApiError {
|
||||
message: format!("JSON parse error: {} — body: {}", e, &text[..text.len().min(200)]),
|
||||
message: format!(
|
||||
"JSON parse error: {} — body: {}",
|
||||
e,
|
||||
&text[..text.len().min(200)]
|
||||
),
|
||||
status_code: status,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,10 +14,18 @@ pub async fn get_dashboard_users(
|
||||
) -> Result<PaginatedUsers, ApiError> {
|
||||
let mut path = "/api/dashboard/users".to_string();
|
||||
let mut params = vec![];
|
||||
if let Some(l) = limit { params.push(format!("limit={}", l)); }
|
||||
if let Some(c) = cursor { params.push(format!("cursor={}", c)); }
|
||||
if let Some(s) = search { params.push(format!("search={}", s)); }
|
||||
if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); }
|
||||
if let Some(l) = limit {
|
||||
params.push(format!("limit={}", l));
|
||||
}
|
||||
if let Some(c) = cursor {
|
||||
params.push(format!("cursor={}", c));
|
||||
}
|
||||
if let Some(s) = search {
|
||||
params.push(format!("search={}", s));
|
||||
}
|
||||
if !params.is_empty() {
|
||||
path.push_str(&format!("?{}", params.join("&")));
|
||||
}
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
@@ -42,11 +50,21 @@ pub async fn get_dashboard_channels(
|
||||
) -> Result<PaginatedChannels, ApiError> {
|
||||
let mut path = "/api/dashboard/channels".to_string();
|
||||
let mut params = vec![];
|
||||
if let Some(l) = limit { params.push(format!("limit={}", l)); }
|
||||
if let Some(c) = cursor { params.push(format!("cursor={}", c)); }
|
||||
if let Some(s) = search { params.push(format!("search={}", s)); }
|
||||
if let Some(g) = guild_id { params.push(format!("guild_id={}", g)); }
|
||||
if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); }
|
||||
if let Some(l) = limit {
|
||||
params.push(format!("limit={}", l));
|
||||
}
|
||||
if let Some(c) = cursor {
|
||||
params.push(format!("cursor={}", c));
|
||||
}
|
||||
if let Some(s) = search {
|
||||
params.push(format!("search={}", s));
|
||||
}
|
||||
if let Some(g) = guild_id {
|
||||
params.push(format!("guild_id={}", g));
|
||||
}
|
||||
if !params.is_empty() {
|
||||
path.push_str(&format!("?{}", params.join("&")));
|
||||
}
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
@@ -58,6 +76,13 @@ pub struct PaginatedChannels {
|
||||
}
|
||||
|
||||
/// GET /api/dashboard/channels/{channelId}
|
||||
pub async fn get_dashboard_channel_detail(channel_id: &str) -> Result<DashboardChannelDetail, ApiError> {
|
||||
request("GET", &format!("/api/dashboard/channels/{}", channel_id), None).await
|
||||
pub async fn get_dashboard_channel_detail(
|
||||
channel_id: &str,
|
||||
) -> Result<DashboardChannelDetail, ApiError> {
|
||||
request(
|
||||
"GET",
|
||||
&format!("/api/dashboard/channels/{}", channel_id),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -9,9 +9,15 @@ pub async fn get_messages(
|
||||
cursor: Option<&str>,
|
||||
) -> Result<PageResult<MessageRecord>, ApiError> {
|
||||
let mut path = format!("/api/messages?guildId={}", guild_id);
|
||||
if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); }
|
||||
if let Some(c) = channel_id { path.push_str(&format!("&channelId={}", c)); }
|
||||
if let Some(c) = cursor { path.push_str(&format!("&cursor={}", c)); }
|
||||
if let Some(l) = limit {
|
||||
path.push_str(&format!("&limit={}", l));
|
||||
}
|
||||
if let Some(c) = channel_id {
|
||||
path.push_str(&format!("&channelId={}", c));
|
||||
}
|
||||
if let Some(c) = cursor {
|
||||
path.push_str(&format!("&cursor={}", c));
|
||||
}
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
@@ -22,8 +28,12 @@ pub async fn get_review_messages(
|
||||
channel_id: Option<&str>,
|
||||
) -> Result<PageResult<MessageRecord>, ApiError> {
|
||||
let mut path = format!("/api/review?guildId={}", guild_id);
|
||||
if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); }
|
||||
if let Some(c) = channel_id { path.push_str(&format!("&channelId={}", c)); }
|
||||
if let Some(l) = limit {
|
||||
path.push_str(&format!("&limit={}", l));
|
||||
}
|
||||
if let Some(c) = channel_id {
|
||||
path.push_str(&format!("&channelId={}", c));
|
||||
}
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
@@ -34,24 +44,40 @@ pub async fn get_message_detail(id: &str) -> Result<Option<MessageRecord>, ApiEr
|
||||
|
||||
/// POST /api/messages/{id}/reanalyze
|
||||
pub async fn reanalyze_message(id: &str) -> Result<(), ApiError> {
|
||||
let _: serde_json::Value = request("POST", &format!("/api/messages/{}/reanalyze", id), Some("{}")).await?;
|
||||
let _: serde_json::Value = request(
|
||||
"POST",
|
||||
&format!("/api/messages/{}/reanalyze", id),
|
||||
Some("{}"),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST /api/messages/reanalyze-batch
|
||||
pub async fn reanalyze_batch() -> Result<u64, ApiError> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BatchResp { ok: bool, count: u64 }
|
||||
#[allow(dead_code)]
|
||||
struct BatchResp {
|
||||
ok: bool,
|
||||
count: u64,
|
||||
}
|
||||
let resp: BatchResp = request("POST", "/api/messages/reanalyze-batch", Some("{}")).await?;
|
||||
Ok(resp.count)
|
||||
}
|
||||
|
||||
/// GET /api/analysis/search?q=&limit=
|
||||
pub async fn search_messages(query: &str, limit: Option<u32>) -> Result<Vec<MessageRecord>, ApiError> {
|
||||
pub async fn search_messages(
|
||||
query: &str,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Vec<MessageRecord>, ApiError> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SearchResult { results: Vec<MessageRecord> }
|
||||
struct SearchResult {
|
||||
results: Vec<MessageRecord>,
|
||||
}
|
||||
let mut path = format!("/api/analysis/search?q={}", query);
|
||||
if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); }
|
||||
if let Some(l) = limit {
|
||||
path.push_str(&format!("&limit={}", l));
|
||||
}
|
||||
let resp: SearchResult = request("GET", &path, None).await?;
|
||||
Ok(resp.results)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub mod client;
|
||||
pub mod auth;
|
||||
pub mod messages;
|
||||
pub mod voice;
|
||||
pub mod client;
|
||||
pub mod dashboard;
|
||||
pub mod mascot;
|
||||
pub mod messages;
|
||||
pub mod recordings;
|
||||
pub mod voice;
|
||||
|
||||
@@ -8,9 +8,15 @@ pub async fn get_recordings(
|
||||
) -> Result<VoiceRecordingListResponse, ApiError> {
|
||||
let mut path = "/api/recordings".to_string();
|
||||
let mut params = vec![];
|
||||
if let Some(l) = limit { params.push(format!("limit={}", l)); }
|
||||
if let Some(c) = cursor { params.push(format!("cursor={}", c)); }
|
||||
if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); }
|
||||
if let Some(l) = limit {
|
||||
params.push(format!("limit={}", l));
|
||||
}
|
||||
if let Some(c) = cursor {
|
||||
params.push(format!("cursor={}", c));
|
||||
}
|
||||
if !params.is_empty() {
|
||||
path.push_str(&format!("?{}", params.join("&")));
|
||||
}
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::api::client::{request, request_no_body, ApiError};
|
||||
use shared_types::voice::VoiceStatus;
|
||||
use shared_types::media::MediaState;
|
||||
use shared_types::guild::{Guild, Channel};
|
||||
use crate::api::client::{request, ApiError};
|
||||
use serde::Serialize;
|
||||
use shared_types::guild::{Channel, Guild};
|
||||
use shared_types::media::MediaState;
|
||||
use shared_types::voice::VoiceStatus;
|
||||
|
||||
/// GET /api/guilds
|
||||
pub async fn get_guilds() -> Result<Vec<Guild>, ApiError> {
|
||||
@@ -11,7 +11,12 @@ pub async fn get_guilds() -> Result<Vec<Guild>, ApiError> {
|
||||
|
||||
/// GET /api/guilds/{guildId}/voice-channels
|
||||
pub async fn get_voice_channels(guild_id: &str) -> Result<Vec<Channel>, ApiError> {
|
||||
request("GET", &format!("/api/guilds/{}/voice-channels", guild_id), None).await
|
||||
request(
|
||||
"GET",
|
||||
&format!("/api/guilds/{}/voice-channels", guild_id),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// GET /api/guilds/{guildId}/channels
|
||||
@@ -35,7 +40,8 @@ pub async fn connect_voice(guild_id: &str, channel_id: &str) -> Result<VoiceStat
|
||||
let body = serde_json::to_string(&ConnectPayload {
|
||||
guild_id: guild_id.to_string(),
|
||||
channel_id: channel_id.to_string(),
|
||||
}).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
request("POST", "/api/voice/connect", Some(&body)).await
|
||||
}
|
||||
|
||||
@@ -59,7 +65,8 @@ pub async fn media_queue(source: &str, mode: &str) -> Result<MediaState, ApiErro
|
||||
let body = serde_json::to_string(&MediaQueuePayload {
|
||||
source: source.to_string(),
|
||||
mode: mode.to_string(),
|
||||
}).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
request("POST", "/api/media/queue", Some(&body)).await
|
||||
}
|
||||
|
||||
@@ -75,7 +82,9 @@ pub async fn media_stop() -> Result<MediaState, ApiError> {
|
||||
|
||||
/// POST /api/media/volume { volume }
|
||||
#[derive(Serialize)]
|
||||
struct VolumePayload { volume: f64 }
|
||||
struct VolumePayload {
|
||||
volume: f64,
|
||||
}
|
||||
pub async fn media_volume(volume: f64) -> Result<MediaState, ApiError> {
|
||||
let body = serde_json::to_string(&VolumePayload { volume }).unwrap();
|
||||
request("POST", "/api/media/volume", Some(&body)).await
|
||||
|
||||
@@ -167,6 +167,7 @@ img {
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
.gap-6 { gap: var(--space-6); }
|
||||
.gap-8 { gap: var(--space-8); }
|
||||
.shrink-0 { flex-shrink: 0; }
|
||||
|
||||
.grid { display: grid; }
|
||||
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
use crate::auth::AuthOverlay;
|
||||
use crate::ws::context::WsContext;
|
||||
use crate::features::dashboard::DashboardPanel;
|
||||
use crate::features::live::LivePanel;
|
||||
use crate::features::messages::MessagesPanel;
|
||||
use crate::features::polish::{initial_theme, ThemeContext};
|
||||
use crate::features::polish::components::{MascotChatbot, ParticleBackground, ThemeToggle};
|
||||
use crate::features::polish::{initial_theme, ThemeContext};
|
||||
use crate::ws::context::WsContext;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
|
||||
/// Derive WebSocket URL from the page's own origin.
|
||||
/// In development (serve on :8080, backend on :3001) use the detected host + /ws path.
|
||||
/// In production (nginx proxies /ws to backend) the same logic works.
|
||||
fn get_ws_url() -> String {
|
||||
web_sys::window()
|
||||
.map(|w| {
|
||||
let loc = w.location();
|
||||
let protocol = loc.protocol().unwrap_or_else(|_| "http:".to_string());
|
||||
let host = loc.host().unwrap_or_else(|_| "localhost:3001".to_string());
|
||||
let ws_proto = if protocol.starts_with("https") {
|
||||
"wss"
|
||||
} else {
|
||||
"ws"
|
||||
};
|
||||
format!("{}://{}/ws", ws_proto, host)
|
||||
})
|
||||
.unwrap_or_else(|| "ws://localhost:3001/ws".to_string())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppConfig {
|
||||
@@ -33,15 +51,15 @@ pub struct UiContext {
|
||||
pub fn App() -> impl IntoView {
|
||||
// Initialize contexts
|
||||
let auth = AuthContext {
|
||||
authenticated: create_rw_signal(false),
|
||||
password: create_rw_signal(String::new()),
|
||||
authenticated: RwSignal::new(false),
|
||||
password: RwSignal::new(String::new()),
|
||||
};
|
||||
let ui = UiContext {
|
||||
active_tab: create_rw_signal(Tab::Messages),
|
||||
selected_guild: create_rw_signal(None),
|
||||
active_tab: RwSignal::new(Tab::Messages),
|
||||
selected_guild: RwSignal::new(None),
|
||||
};
|
||||
let theme = ThemeContext {
|
||||
theme: create_rw_signal(initial_theme()),
|
||||
theme: RwSignal::new(initial_theme()),
|
||||
};
|
||||
|
||||
provide_context(auth.clone());
|
||||
@@ -53,35 +71,15 @@ pub fn App() -> impl IntoView {
|
||||
};
|
||||
provide_context(config);
|
||||
|
||||
let ws = WsContext::new("ws://localhost:3001/ws");
|
||||
let ws = WsContext::new(&get_ws_url());
|
||||
provide_context(ws.clone());
|
||||
|
||||
// Auth check: redirect "live" tab to "messages" if not authenticated
|
||||
create_effect(move |_| {
|
||||
if !auth.authenticated.get() && ui.active_tab.get() == Tab::Live {
|
||||
ui.active_tab.set(Tab::Messages);
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
let ws = ws.clone();
|
||||
let auth = auth.clone();
|
||||
create_effect(move |_| {
|
||||
if auth.authenticated.get() {
|
||||
ws.connect();
|
||||
}
|
||||
});
|
||||
}
|
||||
ws.connect();
|
||||
|
||||
view! {
|
||||
<div data-theme=move || theme.theme.get()>
|
||||
<ParticleBackground />
|
||||
|
||||
// Auth overlay
|
||||
{move || (!auth.authenticated.get()).then(|| {
|
||||
view! { <AuthOverlay /> }
|
||||
})}
|
||||
|
||||
// Main content
|
||||
<div class="app-shell">
|
||||
<header class="app-header">
|
||||
@@ -96,8 +94,8 @@ pub fn App() -> impl IntoView {
|
||||
<nav class="app-sidebar">
|
||||
<div class="flex flex-col gap-2">
|
||||
<TabButton tab=Tab::Messages ui=ui.clone() label="Pesan & Moderasi" />
|
||||
<TabButton tab=Tab::Live ui=ui.clone() label="Voice & Media" />
|
||||
<TabButton tab=Tab::Dashboard ui=ui.clone() label="Dashboard Guild" />
|
||||
<TabButton tab=Tab::Live ui=ui.clone() label="Voice & Media" />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -119,12 +117,8 @@ pub fn App() -> impl IntoView {
|
||||
// ── Tab Button Helper ───────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn TabButton(
|
||||
tab: Tab,
|
||||
ui: UiContext,
|
||||
label: &'static str,
|
||||
) -> impl IntoView {
|
||||
let active_tab = ui.active_tab.clone();
|
||||
fn TabButton(tab: Tab, ui: UiContext, label: &'static str) -> impl IntoView {
|
||||
let active_tab = ui.active_tab;
|
||||
let tab1 = tab.clone();
|
||||
let tab2 = tab.clone();
|
||||
let tab3 = tab.clone();
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// services/frontend-leptos/frontend/src/auth.rs
|
||||
use crate::api::auth as auth_api;
|
||||
use crate::app::AuthContext;
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use crate::app::AuthContext;
|
||||
use crate::api::auth as auth_api;
|
||||
|
||||
#[component]
|
||||
pub fn AuthOverlay() -> impl IntoView {
|
||||
let auth = use_context::<AuthContext>().expect("AuthContext not provided");
|
||||
let (password, set_password) = create_signal(String::new());
|
||||
let (error, set_error) = create_signal(Option::<String>::None);
|
||||
let (loading, set_loading) = create_signal(false);
|
||||
let (password, set_password) = signal(String::new());
|
||||
let (error, set_error) = signal(Option::<String>::None);
|
||||
let (loading, set_loading) = signal(false);
|
||||
|
||||
let handle_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
@@ -23,8 +23,8 @@ pub fn AuthOverlay() -> impl IntoView {
|
||||
|
||||
let auth_clone = auth.clone();
|
||||
let pwd_clone = pwd.clone();
|
||||
let set_loading_clone = set_loading.clone();
|
||||
let set_error_clone = set_error.clone();
|
||||
let set_loading_clone = set_loading;
|
||||
let set_error_clone = set_error;
|
||||
|
||||
spawn_local(async move {
|
||||
match auth_api::login(&pwd_clone).await {
|
||||
|
||||
+9
-3
@@ -79,7 +79,10 @@ pub fn ChannelSummaryList(
|
||||
|
||||
#[component]
|
||||
fn ChannelRow(channel: DashboardChannel) -> impl IntoView {
|
||||
let name = channel.channel_name.clone().unwrap_or_else(|| channel.channel_id.clone());
|
||||
let name = channel
|
||||
.channel_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| channel.channel_id.clone());
|
||||
let summary = channel
|
||||
.culture_summary
|
||||
.clone()
|
||||
@@ -125,7 +128,9 @@ fn format_number(value: u64) -> String {
|
||||
let raw = value.to_string();
|
||||
let mut out = String::new();
|
||||
for (idx, ch) in raw.chars().rev().enumerate() {
|
||||
if idx > 0 && idx % 3 == 0 { out.push(','); }
|
||||
if idx > 0 && idx % 3 == 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out.chars().rev().collect()
|
||||
@@ -133,5 +138,6 @@ fn format_number(value: u64) -> String {
|
||||
|
||||
fn format_timestamp(ts: i64) -> String {
|
||||
let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0));
|
||||
d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED).into()
|
||||
d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub mod channel_summary_list;
|
||||
pub mod stats_overview;
|
||||
pub mod user_summary_list;
|
||||
pub mod channel_summary_list;
|
||||
|
||||
pub use channel_summary_list::ChannelSummaryList;
|
||||
pub use stats_overview::StatsOverview;
|
||||
pub use user_summary_list::UserSummaryList;
|
||||
pub use channel_summary_list::ChannelSummaryList;
|
||||
|
||||
@@ -73,7 +73,12 @@ pub fn StatsOverview(
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn MetricCard(label: &'static str, value: u64, icon: &'static str, tone: &'static str) -> impl IntoView {
|
||||
fn MetricCard(
|
||||
label: &'static str,
|
||||
value: u64,
|
||||
icon: &'static str,
|
||||
tone: &'static str,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="card dashboard-metric-card">
|
||||
<div class="dashboard-metric-content">
|
||||
@@ -115,7 +120,8 @@ fn TopChannels(channels: Vec<TopChannel>) -> impl IntoView {
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
#[component]
|
||||
|
||||
@@ -79,7 +79,10 @@ pub fn UserSummaryList(
|
||||
|
||||
#[component]
|
||||
fn UserRow(user: DashboardUser) -> impl IntoView {
|
||||
let name = user.username.clone().unwrap_or_else(|| user.user_id.clone());
|
||||
let name = user
|
||||
.username
|
||||
.clone()
|
||||
.unwrap_or_else(|| user.user_id.clone());
|
||||
let summary = user
|
||||
.profile_summary
|
||||
.clone()
|
||||
@@ -130,7 +133,9 @@ fn format_number(value: u64) -> String {
|
||||
let raw = value.to_string();
|
||||
let mut out = String::new();
|
||||
for (idx, ch) in raw.chars().rev().enumerate() {
|
||||
if idx > 0 && idx % 3 == 0 { out.push(','); }
|
||||
if idx > 0 && idx % 3 == 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out.chars().rev().collect()
|
||||
@@ -138,5 +143,6 @@ fn format_number(value: u64) -> String {
|
||||
|
||||
fn format_timestamp(ts: i64) -> String {
|
||||
let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0));
|
||||
d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED).into()
|
||||
d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -56,7 +56,13 @@ pub fn DashboardPanel() -> impl IntoView {
|
||||
let search = users_search.get();
|
||||
spawn_local(async move {
|
||||
let search_ref = (!search.trim().is_empty()).then_some(search.trim());
|
||||
match crate::api::dashboard::get_dashboard_users(Some(20), cursor.as_deref(), search_ref).await {
|
||||
match crate::api::dashboard::get_dashboard_users(
|
||||
Some(20),
|
||||
cursor.as_deref(),
|
||||
search_ref,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(page) => {
|
||||
if reset {
|
||||
users.set(page.data);
|
||||
@@ -84,7 +90,14 @@ pub fn DashboardPanel() -> impl IntoView {
|
||||
let search = channels_search.get();
|
||||
spawn_local(async move {
|
||||
let search_ref = (!search.trim().is_empty()).then_some(search.trim());
|
||||
match crate::api::dashboard::get_dashboard_channels(Some(20), cursor.as_deref(), search_ref, None).await {
|
||||
match crate::api::dashboard::get_dashboard_channels(
|
||||
Some(20),
|
||||
cursor.as_deref(),
|
||||
search_ref,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(page) => {
|
||||
if reset {
|
||||
channels.set(page.data);
|
||||
@@ -105,7 +118,7 @@ pub fn DashboardPanel() -> impl IntoView {
|
||||
let fetch_stats = fetch_stats.clone();
|
||||
let fetch_users = fetch_users.clone();
|
||||
let fetch_channels = fetch_channels.clone();
|
||||
create_effect(move |_| {
|
||||
Effect::new(move |_| {
|
||||
fetch_stats();
|
||||
fetch_users(true);
|
||||
fetch_channels(true);
|
||||
@@ -131,67 +144,86 @@ pub fn DashboardPanel() -> impl IntoView {
|
||||
</div>
|
||||
|
||||
<div class="tab-content" style:display=move || if active_tab.get() == DashboardTab::Stats { "block" } else { "none" }>
|
||||
<StatsOverview
|
||||
stats=stats.get()
|
||||
loading=stats_loading.get()
|
||||
error=stats_error.get()
|
||||
on_retry=Box::new({
|
||||
{move || {
|
||||
let on_retry = {
|
||||
let fetch_stats = fetch_stats.clone();
|
||||
move || fetch_stats()
|
||||
})
|
||||
/>
|
||||
Box::new(move || fetch_stats())
|
||||
};
|
||||
view! {
|
||||
<StatsOverview
|
||||
stats=stats.get()
|
||||
loading=stats_loading.get()
|
||||
error=stats_error.get()
|
||||
on_retry=on_retry
|
||||
/>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="tab-content" style:display=move || if active_tab.get() == DashboardTab::Users { "block" } else { "none" }>
|
||||
<UserSummaryList
|
||||
users=users.get()
|
||||
loading=users_loading.get()
|
||||
error=users_error.get()
|
||||
search=users_search.get()
|
||||
has_more=users_cursor.get().is_some()
|
||||
on_search_change=Box::new({
|
||||
{move || {
|
||||
let on_search_change = {
|
||||
let fetch_users = fetch_users.clone();
|
||||
move |value| {
|
||||
Box::new(move |value| {
|
||||
users_search.set(value);
|
||||
users_cursor.set(None);
|
||||
fetch_users(true);
|
||||
}
|
||||
})
|
||||
on_load_more=Box::new({
|
||||
})
|
||||
};
|
||||
let on_load_more = {
|
||||
let fetch_users = fetch_users.clone();
|
||||
move || fetch_users(false)
|
||||
})
|
||||
on_retry=Box::new({
|
||||
Box::new(move || fetch_users(false))
|
||||
};
|
||||
let on_retry = {
|
||||
let fetch_users = fetch_users.clone();
|
||||
move || fetch_users(true)
|
||||
})
|
||||
/>
|
||||
Box::new(move || fetch_users(true))
|
||||
};
|
||||
view! {
|
||||
<UserSummaryList
|
||||
users=users.get()
|
||||
loading=users_loading.get()
|
||||
error=users_error.get()
|
||||
search=users_search.get()
|
||||
has_more=users_cursor.get().is_some()
|
||||
on_search_change=on_search_change
|
||||
on_load_more=on_load_more
|
||||
on_retry=on_retry
|
||||
/>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="tab-content" style:display=move || if active_tab.get() == DashboardTab::Channels { "block" } else { "none" }>
|
||||
<ChannelSummaryList
|
||||
channels=channels.get()
|
||||
loading=channels_loading.get()
|
||||
error=channels_error.get()
|
||||
search=channels_search.get()
|
||||
has_more=channels_cursor.get().is_some()
|
||||
on_search_change=Box::new({
|
||||
{move || {
|
||||
let on_search_change = {
|
||||
let fetch_channels = fetch_channels.clone();
|
||||
move |value| {
|
||||
Box::new(move |value| {
|
||||
channels_search.set(value);
|
||||
channels_cursor.set(None);
|
||||
fetch_channels(true);
|
||||
}
|
||||
})
|
||||
on_load_more=Box::new({
|
||||
})
|
||||
};
|
||||
let on_load_more = {
|
||||
let fetch_channels = fetch_channels.clone();
|
||||
move || fetch_channels(false)
|
||||
})
|
||||
on_retry=Box::new({
|
||||
Box::new(move || fetch_channels(false))
|
||||
};
|
||||
let on_retry = {
|
||||
let fetch_channels = fetch_channels.clone();
|
||||
move || fetch_channels(true)
|
||||
})
|
||||
/>
|
||||
Box::new(move || fetch_channels(true))
|
||||
};
|
||||
view! {
|
||||
<ChannelSummaryList
|
||||
channels=channels.get()
|
||||
loading=channels_loading.get()
|
||||
error=channels_error.get()
|
||||
search=channels_search.get()
|
||||
has_more=channels_cursor.get().is_some()
|
||||
on_search_change=on_search_change
|
||||
on_load_more=on_load_more
|
||||
on_retry=on_retry
|
||||
/>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
pub mod ring_buffer;
|
||||
pub mod pcm_decoder;
|
||||
pub mod ring_buffer;
|
||||
|
||||
@@ -46,7 +46,7 @@ pub fn encode_samples_to_base64(samples: &[f32]) -> String {
|
||||
// Convert f32 samples to i16 bytes
|
||||
let mut bytes = Vec::with_capacity(samples.len() * 2);
|
||||
for &sample in samples {
|
||||
let clamped = sample.max(-1.0).min(1.0);
|
||||
let clamped = sample.clamp(-1.0, 1.0);
|
||||
let int_sample = (clamped * 32767.0) as i16;
|
||||
bytes.extend_from_slice(&int_sample.to_le_bytes());
|
||||
}
|
||||
@@ -65,5 +65,3 @@ fn encode_bytes_base64(data: &[u8]) -> String {
|
||||
.and_then(|r| r.as_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
@@ -22,36 +22,36 @@ pub fn ActiveSpeakers(
|
||||
key=|s| s.user_id.clone() + &s.username
|
||||
let:speaker
|
||||
>
|
||||
<div class="flex items-center gap-3 rounded-xl border border-border bg-card p-3">
|
||||
<div class="h-8 w-8 flex-shrink-0">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;border-radius:0.75rem;border:1px solid var(--surface-border);background:var(--surface-base);padding:0.75rem">
|
||||
<div style="width:2rem;height:2rem;flex-shrink:0">
|
||||
{speaker.avatar.as_ref().map(|avatar_url| {
|
||||
let url = avatar_url.clone();
|
||||
view! {
|
||||
<img
|
||||
src=url
|
||||
alt=""
|
||||
class="h-8 w-8 rounded-full object-cover ring-2 ring-primary/30"
|
||||
style="width:2rem;height:2rem;border-radius:9999px;object-fit:cover;box-shadow:0 0 0 2px rgba(35,161,235,0.3)"
|
||||
/>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div style="min-width:0;flex:1">
|
||||
<div class="truncate text-sm font-medium">
|
||||
{speaker.username.clone()}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class=move || {
|
||||
<div style="display:flex;align-items:center;gap:0.375rem">
|
||||
<span style=move || {
|
||||
if speaker.speaking {
|
||||
"inline-block h-2 w-2 rounded-full bg-emerald-500"
|
||||
"display:inline-block;width:0.5rem;height:0.5rem;border-radius:9999px;background:#10b981"
|
||||
} else {
|
||||
"inline-block h-2 w-2 rounded-full bg-muted-foreground/40"
|
||||
"display:inline-block;width:0.5rem;height:0.5rem;border-radius:9999px;background:color-mix(in srgb, var(--text-tertiary) 40%, transparent)"
|
||||
}
|
||||
}></span>
|
||||
<span class=move || {
|
||||
<span style=move || {
|
||||
if speaker.speaking {
|
||||
"text-xs font-medium text-emerald-600 dark:text-emerald-400"
|
||||
"font-size:0.75rem;font-weight:500;color:#059669"
|
||||
} else {
|
||||
"text-xs font-medium text-muted-foreground"
|
||||
"font-size:0.75rem;font-weight:500;color:var(--text-secondary)"
|
||||
}
|
||||
}>
|
||||
{move || if speaker.speaking { "Speaking" } else { "Silent" }}
|
||||
@@ -64,12 +64,12 @@ pub fn ActiveSpeakers(
|
||||
}
|
||||
}
|
||||
>
|
||||
<div class="rounded-xl border border-border bg-card p-8 text-center shadow-sm">
|
||||
<div class="space-y-2">
|
||||
<div class="text-4xl">
|
||||
<div style="border-radius:0.75rem;border:1px solid var(--surface-border);background:var(--surface-base);padding:2rem;text-align:center">
|
||||
<div>
|
||||
<div style="font-size:2.25rem;line-height:2.5rem">
|
||||
"🎤"
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<p style="font-size:0.875rem;color:var(--text-secondary)">
|
||||
"No active speakers"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -8,17 +8,17 @@ pub fn AudioVisualizer(
|
||||
#[prop(default = true)] _active: bool,
|
||||
#[prop(optional)] pcm_data: Option<Arc<Mutex<Vec<f32>>>>,
|
||||
) -> impl IntoView {
|
||||
let bars = create_rw_signal::<Vec<f32>>(vec![0.0; 32]);
|
||||
let bars = RwSignal::new(vec![0.0; 32]);
|
||||
|
||||
// Periodically update bars from PCM data
|
||||
create_effect(move |_| {
|
||||
Effect::new(move |_| {
|
||||
if let Some(ref pcm_arc) = pcm_data {
|
||||
if let Ok(pcm_vec) = pcm_arc.lock() {
|
||||
let computed = compute_frequency_bands(&pcm_vec);
|
||||
bars.update(|b| {
|
||||
for i in 0..32 {
|
||||
let target = computed.get(i).copied().unwrap_or(0.0).max(0.0).min(1.0);
|
||||
b[i] = b[i] * 0.7 + target * 0.3; // Smooth decay
|
||||
for (i, band) in b.iter_mut().enumerate() {
|
||||
let target = computed.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
*band = *band * 0.7 + target * 0.3; // Smooth decay
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ pub fn MicLevelMeter(
|
||||
#[prop(optional)] pcm_data: Option<Arc<Mutex<Vec<f32>>>>,
|
||||
#[prop(optional)] label: Option<&'static str>,
|
||||
) -> impl IntoView {
|
||||
let level = create_rw_signal::<f32>(0.0);
|
||||
let peak = create_rw_signal::<f32>(0.0);
|
||||
let level = RwSignal::new(0.0f32);
|
||||
let peak = RwSignal::new(0.0f32);
|
||||
|
||||
// Update level periodically
|
||||
create_effect(move |_| {
|
||||
Effect::new(move |_| {
|
||||
if !active {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
pub mod voice_connection_card;
|
||||
pub mod active_speakers;
|
||||
pub mod audio_visualizer;
|
||||
pub mod mic_level_meter;
|
||||
pub mod now_playing;
|
||||
pub mod music_sub_panel;
|
||||
pub mod screen_sub_panel;
|
||||
pub mod now_playing;
|
||||
pub mod recordings_sub_panel;
|
||||
pub mod screen_sub_panel;
|
||||
pub mod voice_connection_card;
|
||||
pub mod waveform_player;
|
||||
|
||||
pub use voice_connection_card::VoiceConnectionCard;
|
||||
pub use active_speakers::ActiveSpeakers;
|
||||
pub use audio_visualizer::AudioVisualizer;
|
||||
pub use mic_level_meter::MicLevelMeter;
|
||||
pub use now_playing::NowPlaying;
|
||||
pub use music_sub_panel::MusicSubPanel;
|
||||
pub use screen_sub_panel::ScreenSubPanel;
|
||||
pub use now_playing::NowPlaying;
|
||||
pub use recordings_sub_panel::RecordingsSubPanel;
|
||||
pub use screen_sub_panel::ScreenSubPanel;
|
||||
pub use voice_connection_card::VoiceConnectionCard;
|
||||
pub use waveform_player::WaveformPlayer;
|
||||
|
||||
@@ -5,11 +5,11 @@ use leptos::prelude::*;
|
||||
pub fn MusicSubPanel(
|
||||
#[prop(optional)] on_queue: Option<Box<dyn Fn(String) + Send + Sync + 'static>>,
|
||||
) -> impl IntoView {
|
||||
let (url_input, set_url_input) = create_signal::<String>(String::new());
|
||||
let (is_loading, set_is_loading) = create_signal::<bool>(false);
|
||||
let (url_input, set_url_input) = signal::<String>(String::new());
|
||||
let (is_loading, set_is_loading) = signal::<bool>(false);
|
||||
|
||||
let handle_queue_click = move |_| {
|
||||
let url = url_input.get().trim().to_string();
|
||||
let url = url_input.get_untracked().trim().to_string();
|
||||
if !url.is_empty() {
|
||||
if let Some(ref cb) = on_queue {
|
||||
set_is_loading.set(true);
|
||||
@@ -24,7 +24,7 @@ pub fn MusicSubPanel(
|
||||
<div class="music-sub-panel card">
|
||||
<div class="card-header">
|
||||
<div class="card-title flex items-center gap-2">
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg width="16" height="16" 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>
|
||||
<path d="M9 8h6v8h-6z"></path>
|
||||
</svg>
|
||||
|
||||
@@ -8,7 +8,7 @@ pub fn NowPlaying(
|
||||
#[prop(optional)] on_skip: Option<Box<dyn Fn() + Send + Sync + 'static>>,
|
||||
#[prop(optional)] on_stop: Option<Box<dyn Fn() + Send + Sync + 'static>>,
|
||||
) -> impl IntoView {
|
||||
let media_state = create_rw_signal::<Option<MediaState>>(state);
|
||||
let media_state = RwSignal::new(state);
|
||||
|
||||
// Wrap callbacks in StoredValue for shareable non-Clone ownership in Leptos context
|
||||
let skip_cb = StoredValue::new(on_skip);
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
use crate::api::recordings::{delete_recording, get_recordings};
|
||||
use leptos::prelude::*;
|
||||
use shared_types::recording::VoiceRecording;
|
||||
use crate::api::recordings::{get_recordings, delete_recording};
|
||||
|
||||
/// RecordingsSubPanel — Paginated list of voice recordings
|
||||
#[component]
|
||||
pub fn RecordingsSubPanel() -> impl IntoView {
|
||||
let recordings = create_rw_signal::<Vec<VoiceRecording>>(Vec::new());
|
||||
let loading = create_rw_signal::<bool>(false);
|
||||
let has_more = create_rw_signal::<bool>(true);
|
||||
let next_cursor = create_rw_signal::<Option<String>>(None);
|
||||
let recordings = RwSignal::new(Vec::<VoiceRecording>::new());
|
||||
let loading = RwSignal::new(false);
|
||||
let has_more = RwSignal::new(true);
|
||||
let next_cursor = RwSignal::new(None::<String>);
|
||||
|
||||
// Load recordings
|
||||
let load = move |reset: bool| {
|
||||
if loading.get() { return; }
|
||||
if loading.get_untracked() {
|
||||
return;
|
||||
}
|
||||
loading.set(true);
|
||||
|
||||
let cursor_val = if reset { None } else { next_cursor.get() };
|
||||
let cursor_val = if reset {
|
||||
None
|
||||
} else {
|
||||
next_cursor.get_untracked()
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local({
|
||||
async move {
|
||||
match get_recordings(Some(20), cursor_val.as_deref()).await {
|
||||
@@ -23,7 +29,7 @@ pub fn RecordingsSubPanel() -> impl IntoView {
|
||||
if reset {
|
||||
recordings.set(resp.items);
|
||||
} else {
|
||||
let mut current = recordings.get();
|
||||
let mut current = recordings.get_untracked();
|
||||
current.extend(resp.items);
|
||||
recordings.set(current);
|
||||
}
|
||||
@@ -42,7 +48,7 @@ pub fn RecordingsSubPanel() -> impl IntoView {
|
||||
};
|
||||
|
||||
// Load on mount
|
||||
create_effect(move |_| {
|
||||
Effect::new(move |_| {
|
||||
load(true);
|
||||
});
|
||||
|
||||
@@ -61,7 +67,7 @@ pub fn RecordingsSubPanel() -> impl IntoView {
|
||||
<div class="recordings-sub-panel card">
|
||||
<div class="card-header">
|
||||
<div class="card-title flex items-center gap-2">
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
||||
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||
@@ -106,7 +112,7 @@ pub fn RecordingsSubPanel() -> impl IntoView {
|
||||
<span>{created_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<div class="flex items-center" style="gap:0.375rem;flex-shrink:0">
|
||||
{has_url.then(|| {
|
||||
view! {
|
||||
<a
|
||||
@@ -166,5 +172,6 @@ fn format_size(bytes: u64) -> String {
|
||||
/// Format timestamp i64 to readable date
|
||||
fn format_timestamp(ts: i64) -> String {
|
||||
let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0));
|
||||
d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED).into()
|
||||
d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ pub fn ScreenSubPanel(
|
||||
#[prop(optional)] on_start_stream: Option<Box<dyn Fn() + Send + Sync + 'static>>,
|
||||
#[prop(optional)] on_stop_stream: Option<Box<dyn Fn() + Send + Sync + 'static>>,
|
||||
) -> impl IntoView {
|
||||
let (is_streaming, set_is_streaming) = create_signal::<bool>(false);
|
||||
let (is_streaming, set_is_streaming) = signal::<bool>(false);
|
||||
|
||||
let has_start = on_start_stream.is_some();
|
||||
let has_stop = on_stop_stream.is_some();
|
||||
@@ -15,7 +15,7 @@ pub fn ScreenSubPanel(
|
||||
<div class="screen-sub-panel card">
|
||||
<div class="card-header">
|
||||
<div class="card-title flex items-center gap-2">
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||
@@ -35,7 +35,7 @@ pub fn ScreenSubPanel(
|
||||
class=move || format!("btn btn-success flex-1 {}", if is_streaming.get() { "opacity-50" } else { "" })
|
||||
disabled=move || is_streaming.get()
|
||||
on:click=move |_| {
|
||||
if !is_streaming.get() {
|
||||
if !is_streaming.get_untracked() {
|
||||
set_is_streaming.set(true);
|
||||
if let Some(ref cb) = on_start_stream {
|
||||
cb();
|
||||
@@ -54,7 +54,7 @@ pub fn ScreenSubPanel(
|
||||
class=move || format!("btn btn-destructive flex-1 {}", if !is_streaming.get() { "opacity-50" } else { "" })
|
||||
disabled=move || !is_streaming.get()
|
||||
on:click=move |_| {
|
||||
if is_streaming.get() {
|
||||
if is_streaming.get_untracked() {
|
||||
set_is_streaming.set(false);
|
||||
if let Some(ref cb) = on_stop_stream {
|
||||
cb();
|
||||
@@ -81,4 +81,3 @@ pub fn ScreenSubPanel(
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use crate::features::live::hooks::use_voice_control::{use_voice_control, VoiceControlState};
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
/// VoiceConnectionCard component for Leptos
|
||||
/// Renders guild and voice channel selectors with connect/disconnect controls
|
||||
@@ -13,12 +13,12 @@ pub fn VoiceConnectionCard(
|
||||
let state = voice_state.unwrap_or(default_state);
|
||||
|
||||
// Reactive signal for selected guild
|
||||
let (selected_guild, set_selected_guild) = create_signal::<String>(String::new());
|
||||
let (selected_guild, set_selected_guild) = signal::<String>(String::new());
|
||||
// Reactive signal for selected channel
|
||||
let (selected_channel, set_selected_channel) = create_signal::<String>(String::new());
|
||||
let (selected_channel, set_selected_channel) = signal::<String>(String::new());
|
||||
|
||||
// When guild is selected, load voice channels
|
||||
create_effect(move |_| {
|
||||
Effect::new(move |_| {
|
||||
let guild_id = selected_guild.get();
|
||||
if !guild_id.is_empty() {
|
||||
(state.load_voice_channels)(guild_id);
|
||||
@@ -26,7 +26,7 @@ pub fn VoiceConnectionCard(
|
||||
});
|
||||
|
||||
// Load guilds on mount
|
||||
create_effect(move |_| {
|
||||
Effect::new(move |_| {
|
||||
(state.load_guilds)();
|
||||
});
|
||||
|
||||
@@ -65,17 +65,13 @@ pub fn VoiceConnectionCard(
|
||||
let error = state.error;
|
||||
let voice_status = state.voice_status;
|
||||
|
||||
let is_connected = move || {
|
||||
voice_status.get().map(|s| s.connected).unwrap_or(false)
|
||||
};
|
||||
let is_connected = move || voice_status.get().map(|s| s.connected).unwrap_or(false);
|
||||
|
||||
let can_join = move || {
|
||||
!selected_guild.get().is_empty() && !selected_channel.get().is_empty() && !loading.get()
|
||||
};
|
||||
|
||||
let can_disconnect = move || {
|
||||
is_connected() && !loading.get()
|
||||
};
|
||||
let can_disconnect = move || is_connected() && !loading.get();
|
||||
|
||||
view! {
|
||||
<div class=format!("rounded-xl border border-border bg-card shadow-sm {}", class)>
|
||||
@@ -213,7 +209,8 @@ pub fn VoiceConnectionCard(
|
||||
</span>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! { <></> }.into_any()
|
||||
let _: () = view! { <></> };
|
||||
().into_any()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
/// WaveformPlayer — Audio player with waveform progress bar
|
||||
#[component]
|
||||
@@ -7,10 +7,10 @@ pub fn WaveformPlayer(
|
||||
audio_url: String,
|
||||
#[prop(default = "Recording".to_string())] title: String,
|
||||
) -> impl IntoView {
|
||||
let is_playing = create_rw_signal::<bool>(false);
|
||||
let current_time = create_rw_signal::<f64>(0.0);
|
||||
let duration = create_rw_signal::<f64>(0.0);
|
||||
let audio_id = format!("audio_{}", &audio_url);
|
||||
let is_playing = RwSignal::new(false);
|
||||
let current_time = RwSignal::new(0.0);
|
||||
let duration = RwSignal::new(0.0);
|
||||
let audio_id = format!("audio_{}", audio_url);
|
||||
|
||||
// Clone audio_url for the audio element
|
||||
let audio_src = audio_url.clone();
|
||||
@@ -18,17 +18,17 @@ pub fn WaveformPlayer(
|
||||
|
||||
let toggle_play = move |_| {
|
||||
let doc = web_sys::window().unwrap().document().unwrap();
|
||||
let audio_opt = doc.get_element_by_id(&format!("audio_{}", &audio_src_for_id));
|
||||
let audio_opt = doc.get_element_by_id(&format!("audio_{}", audio_src_for_id));
|
||||
if let Some(audio_el) = audio_opt {
|
||||
if let Ok(audio) = audio_el.dyn_into::<web_sys::HtmlAudioElement>() {
|
||||
if is_playing.get() {
|
||||
if is_playing.get_untracked() {
|
||||
let _ = audio.pause();
|
||||
is_playing.set(false);
|
||||
} else {
|
||||
if audio.ended() {
|
||||
audio.set_current_time(0.0);
|
||||
}
|
||||
if let Ok(_) = audio.play() {
|
||||
if audio.play().is_ok() {
|
||||
is_playing.set(true);
|
||||
}
|
||||
}
|
||||
@@ -87,11 +87,17 @@ pub fn WaveformPlayer(
|
||||
}
|
||||
|
||||
fn progress_pct(current: f64, dur: f64) -> f64 {
|
||||
if dur > 0.0 { (current / dur * 100.0).min(100.0) } else { 0.0 }
|
||||
if dur > 0.0 {
|
||||
(current / dur * 100.0).min(100.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn format_time(secs: f64) -> String {
|
||||
if !secs.is_finite() || secs < 0.0 { return "00:00".to_string(); }
|
||||
if !secs.is_finite() || secs < 0.0 {
|
||||
return "00:00".to_string();
|
||||
}
|
||||
let total = secs as u32;
|
||||
format!("{:02}:{:02}", total / 60, total % 60)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pub mod use_voice_control;
|
||||
pub mod use_media_control;
|
||||
pub mod use_audio_playback;
|
||||
pub mod use_audio_transmit;
|
||||
pub mod use_media_control;
|
||||
pub mod use_voice_control;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use leptos::prelude::*;
|
||||
use std::sync::Arc;
|
||||
use crate::features::live::audio::pcm_decoder::decode_pcm_frame;
|
||||
use crate::features::live::audio::ring_buffer::SharedRingBuffer;
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// AudioPlaybackState — Manages PCM audio playback from WebSocket binary frames
|
||||
pub struct AudioPlaybackState {
|
||||
@@ -16,8 +15,8 @@ pub struct AudioPlaybackState {
|
||||
/// Create and initialize audio playback state
|
||||
pub fn use_audio_playback() -> AudioPlaybackState {
|
||||
let buffer = SharedRingBuffer::new(44100 * 5); // 5 seconds at 44.1kHz
|
||||
let active = create_rw_signal::<bool>(false);
|
||||
let volume = create_rw_signal::<f64>(0.5);
|
||||
let active = RwSignal::new(false);
|
||||
let volume = RwSignal::new(0.5);
|
||||
|
||||
AudioPlaybackState {
|
||||
buffer,
|
||||
@@ -36,7 +35,7 @@ pub fn process_pcm_data(state: &AudioPlaybackState, data: Vec<u8>) {
|
||||
|
||||
/// Start consuming the ring buffer and playing through AudioContext
|
||||
pub fn start_playback(state: &AudioPlaybackState) {
|
||||
if state.active.get() {
|
||||
if state.active.get_untracked() {
|
||||
return;
|
||||
}
|
||||
state.active.set(true);
|
||||
@@ -56,7 +55,7 @@ pub fn start_playback(state: &AudioPlaybackState) {
|
||||
let ctx_ref = &ctx;
|
||||
let _ = ctx_ref.resume();
|
||||
|
||||
while active.get() {
|
||||
while active.get_untracked() {
|
||||
let available = buffer.available_samples();
|
||||
if available >= 4410 {
|
||||
// ~100ms worth at 44.1kHz
|
||||
@@ -90,7 +89,7 @@ fn play_samples(ctx: &web_sys::AudioContext, samples: &[f32]) {
|
||||
return;
|
||||
};
|
||||
|
||||
let len = samples.len().min(channel_data.len() as usize);
|
||||
let len = samples.len().min(channel_data.len());
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::{MediaStream, MediaStreamConstraints, MediaStreamTrack};
|
||||
|
||||
@@ -11,14 +11,14 @@ pub struct AudioTransmitState {
|
||||
|
||||
/// Create microphone transmit state
|
||||
pub fn use_audio_transmit() -> AudioTransmitState {
|
||||
let active = create_rw_signal::<bool>(false);
|
||||
let active = RwSignal::new(false);
|
||||
let stream = StoredValue::new(None::<MediaStream>);
|
||||
AudioTransmitState { active, stream }
|
||||
}
|
||||
|
||||
/// Start microphone capture - requests getUserMedia and stores the stream
|
||||
pub fn start_transmit(state: &AudioTransmitState) {
|
||||
if state.active.get() {
|
||||
if state.active.get_untracked() {
|
||||
return;
|
||||
}
|
||||
state.active.set(true);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use crate::api::voice::{get_media_status, media_queue, media_skip, media_stop, media_volume};
|
||||
use leptos::prelude::*;
|
||||
use shared_types::media::MediaState;
|
||||
use crate::api::voice::{
|
||||
get_media_status, media_queue, media_skip, media_stop, media_volume,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::guild::{Guild, Channel};
|
||||
use shared_types::voice::VoiceStatus;
|
||||
use crate::api::voice::{
|
||||
get_guilds, get_voice_channels, get_text_channels, get_voice_status,
|
||||
connect_voice, disconnect_voice,
|
||||
connect_voice, disconnect_voice, get_guilds, get_text_channels, get_voice_channels,
|
||||
};
|
||||
use leptos::prelude::*;
|
||||
use shared_types::guild::{Channel, Guild};
|
||||
use shared_types::voice::VoiceStatus;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
|
||||
@@ -1,65 +1,77 @@
|
||||
pub mod audio;
|
||||
pub mod components;
|
||||
pub mod hooks;
|
||||
pub mod audio;
|
||||
|
||||
use leptos::prelude::*;
|
||||
use crate::ws::context::WsContext;
|
||||
use crate::app::AuthContext;
|
||||
use crate::auth::AuthOverlay;
|
||||
use components::{
|
||||
VoiceConnectionCard, ActiveSpeakers, AudioVisualizer,
|
||||
NowPlaying, MusicSubPanel, ScreenSubPanel, RecordingsSubPanel,
|
||||
ActiveSpeakers, AudioVisualizer, MusicSubPanel, NowPlaying, RecordingsSubPanel, ScreenSubPanel,
|
||||
VoiceConnectionCard,
|
||||
};
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// LivePanel — Composition shell for all voice and media components
|
||||
/// LivePanel — Composition shell for all voice and media components.
|
||||
/// Shows an auth overlay if not authenticated, otherwise shows voice controls.
|
||||
#[component]
|
||||
pub fn LivePanel() -> impl IntoView {
|
||||
let ws = use_context::<WsContext>();
|
||||
let auth = use_context::<AuthContext>().expect("AuthContext not provided");
|
||||
|
||||
view! {
|
||||
<div class="live-panel space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">"Voice & Media"</h2>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
"Monitor voice channels, play music, share your screen, and browse recordings."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-panel">
|
||||
{move || {
|
||||
if auth.authenticated.get() {
|
||||
view! {
|
||||
<div class="live-panel space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">"Voice & Media"</h2>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
"Monitor voice channels, play music, share your screen, and browse recordings."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top row: Voice connection + speakers + visualizer */}
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
<VoiceConnectionCard />
|
||||
</div>
|
||||
<div>
|
||||
<ActiveSpeakers />
|
||||
</div>
|
||||
</div>
|
||||
{/* Top row: Voice connection + speakers + visualizer */}
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
<VoiceConnectionCard />
|
||||
</div>
|
||||
<div>
|
||||
<ActiveSpeakers />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audio visualization */}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Audio Visualization"</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<AudioVisualizer />
|
||||
</div>
|
||||
</div>
|
||||
{/* Audio visualization */}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Audio Visualization"</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<AudioVisualizer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media controls: Now Playing + Music + Screen */}
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div>
|
||||
<NowPlaying />
|
||||
</div>
|
||||
<div>
|
||||
<MusicSubPanel />
|
||||
</div>
|
||||
<div>
|
||||
<ScreenSubPanel />
|
||||
</div>
|
||||
</div>
|
||||
{/* Media controls: Now Playing + Music + Screen */}
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div>
|
||||
<NowPlaying />
|
||||
</div>
|
||||
<div>
|
||||
<MusicSubPanel />
|
||||
</div>
|
||||
<div>
|
||||
<ScreenSubPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recordings */}
|
||||
<RecordingsSubPanel />
|
||||
{/* Recordings */}
|
||||
<RecordingsSubPanel />
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! { <AuthOverlay /> }.into_any()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ use leptos::prelude::*;
|
||||
use shared_types::message::MessageRecord;
|
||||
|
||||
#[component]
|
||||
pub fn ImageGrid(
|
||||
messages: Vec<MessageRecord>,
|
||||
) -> impl IntoView {
|
||||
pub fn ImageGrid(messages: Vec<MessageRecord>) -> impl IntoView {
|
||||
let mut seen_urls = std::collections::HashSet::new();
|
||||
let mut urls = Vec::new();
|
||||
|
||||
@@ -13,7 +11,11 @@ pub fn ImageGrid(
|
||||
// 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)
|
||||
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")
|
||||
@@ -57,7 +59,8 @@ pub fn ImageGrid(
|
||||
<div class="flex items-center justify-center h-32 text-secondary italic">
|
||||
"No images found"
|
||||
</div>
|
||||
}.into_any();
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
|
||||
view! {
|
||||
@@ -71,5 +74,6 @@ pub fn ImageGrid(
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
@@ -28,9 +28,12 @@ fn render_emojis(content: &str) -> Vec<AnyView> {
|
||||
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());
|
||||
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() {
|
||||
@@ -70,14 +73,17 @@ fn severity_class(s: &AiSeverity) -> &'static str {
|
||||
}
|
||||
|
||||
fn is_fallback(t: &str) -> bool {
|
||||
t.starts_with("[Attachment:")
|
||||
|| t.starts_with("[Sticker:")
|
||||
|| t.starts_with("[Embed]")
|
||||
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())
|
||||
.map(|v| {
|
||||
v.iter()
|
||||
.filter(|c| *c != "analysis_incomplete")
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -88,9 +94,15 @@ fn StatusBadgeInline(status: AiStatus) -> impl IntoView {
|
||||
AiStatus::Clean => ("status-badge-clean", 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", 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", 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", view! { }.into_any()),
|
||||
AiStatus::Processing => ("status-badge-processing", view! { }.into_any()),
|
||||
AiStatus::Warn => ("status-badge-warn", view! { }.into_any()),
|
||||
AiStatus::Pending => {
|
||||
("status-badge-pending", ().into_any())
|
||||
},
|
||||
AiStatus::Processing => {
|
||||
("status-badge-processing", ().into_any())
|
||||
},
|
||||
AiStatus::Warn => {
|
||||
("status-badge-warn", ().into_any())
|
||||
},
|
||||
};
|
||||
view! {
|
||||
<span class=format!("status-badge {}", cl)>
|
||||
@@ -108,7 +120,10 @@ pub fn MessageRow(
|
||||
) -> 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 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);
|
||||
|
||||
@@ -117,31 +132,58 @@ pub fn MessageRow(
|
||||
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)));
|
||||
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<AttachmentRef> = 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")
|
||||
}).cloned().collect();
|
||||
let vids: Vec<AttachmentRef> = 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")
|
||||
}).cloned().collect();
|
||||
let all_atts = message
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.attachments.as_ref())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let imgs: Vec<AttachmentRef> = 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")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let vids: Vec<AttachmentRef> = 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")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let stickers = message.metadata.as_ref()
|
||||
.and_then(|m| m.stickers.as_ref()).cloned().unwrap_or_default();
|
||||
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());
|
||||
@@ -236,7 +278,7 @@ pub fn MessageRow(
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
().into_any()
|
||||
};
|
||||
view! {
|
||||
<div class="flex gap-2 overflow-x-auto">
|
||||
@@ -245,7 +287,7 @@ pub fn MessageRow(
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
().into_any()
|
||||
}}
|
||||
|
||||
{/* Videos */}
|
||||
@@ -265,7 +307,7 @@ pub fn MessageRow(
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
().into_any()
|
||||
};
|
||||
view! {
|
||||
<div class="flex gap-2 overflow-x-auto">
|
||||
@@ -274,7 +316,7 @@ pub fn MessageRow(
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
().into_any()
|
||||
}}
|
||||
|
||||
{/* Categories */}
|
||||
@@ -288,7 +330,7 @@ pub fn MessageRow(
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
().into_any()
|
||||
}}
|
||||
|
||||
{/* AI Analysis */}
|
||||
@@ -347,16 +389,26 @@ pub fn MessageCard(
|
||||
let first = &messages[0];
|
||||
let has_multi = messages.len() > 1;
|
||||
let deleted = first.deleted_at.is_some();
|
||||
let avatar = first.avatar_url.clone()
|
||||
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 { "" };
|
||||
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)>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use leptos::html;
|
||||
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;
|
||||
|
||||
@@ -11,10 +11,12 @@ 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()
|
||||
let same_user = last_group
|
||||
.first()
|
||||
.map(|m| m.user_id == msg.user_id)
|
||||
.unwrap_or(false);
|
||||
let same_window = last_group.last()
|
||||
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 {
|
||||
@@ -37,11 +39,11 @@ pub fn MessageFeed(
|
||||
#[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);
|
||||
let sentinel_ref = NodeRef::<html::Div>::new();
|
||||
let (_intersecting, _set_intersecting) = signal(false);
|
||||
|
||||
create_effect(move |_| {
|
||||
let _ = intersecting.get(); // track signal
|
||||
Effect::new(move |_| {
|
||||
let _ = _intersecting.get(); // track signal
|
||||
if let Some(node) = sentinel_ref.get() {
|
||||
let on_load_more = on_load_more.clone();
|
||||
let cb = Closure::<dyn Fn(Vec<JsValue>)>::new(move |entries: Vec<JsValue>| {
|
||||
@@ -75,7 +77,8 @@ pub fn MessageFeed(
|
||||
view! { <MessageCardSkeleton /> }
|
||||
}).take(3).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any();
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
|
||||
if messages.is_empty() {
|
||||
@@ -85,7 +88,8 @@ pub fn MessageFeed(
|
||||
{if empty_text.is_empty() { "No messages" } else { empty_text }}
|
||||
</div>
|
||||
</div>
|
||||
}.into_any();
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
|
||||
let groups = group_messages(messages);
|
||||
@@ -113,7 +117,8 @@ pub fn MessageFeed(
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
#[component]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod message_feed;
|
||||
pub mod message_card;
|
||||
pub mod image_grid;
|
||||
pub mod message_card;
|
||||
pub mod message_feed;
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
use crate::api::messages::{get_messages, reanalyze_batch, reanalyze_message};
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::{MessageRecord, PageResult};
|
||||
use crate::api::messages::{get_messages, reanalyze_message, reanalyze_batch};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
/// Merges current messages with incoming messages, deduplicating by ID and sorting
|
||||
pub fn merge_messages(current: &[MessageRecord], incoming: &[MessageRecord]) -> Vec<MessageRecord> {
|
||||
let mut by_id: HashMap<String, MessageRecord> = current.iter().map(|m| (m.id.clone(), m.clone())).collect();
|
||||
let mut by_id: HashMap<String, MessageRecord> =
|
||||
current.iter().map(|m| (m.id.clone(), m.clone())).collect();
|
||||
for msg in incoming {
|
||||
by_id.insert(msg.id.clone(), msg.clone());
|
||||
}
|
||||
let mut merged: Vec<MessageRecord> = by_id.into_values().collect();
|
||||
merged.sort_by(|a, b| b.created_at.cmp(&a.created_at).then_with(|| b.id.cmp(&a.id)));
|
||||
merged.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.cmp(&a.id))
|
||||
});
|
||||
merged
|
||||
}
|
||||
|
||||
@@ -56,14 +61,14 @@ pub struct MessagesState {
|
||||
pub fn use_messages() -> MessagesState {
|
||||
// Core signals
|
||||
let messages_signal = RwSignal::new(Vec::<MessageRecord>::new());
|
||||
let (loading, set_loading) = create_signal(false);
|
||||
let (loading, set_loading) = signal(false);
|
||||
let loading_more_signal = RwSignal::new(false);
|
||||
let cursor_signal = RwSignal::new(None::<String>);
|
||||
let error_signal = RwSignal::new(None::<String>);
|
||||
let current_guild_signal = RwSignal::new(None::<String>);
|
||||
|
||||
// Derived signal: has_more is true if cursor is Some
|
||||
let has_more_signal = create_memo(move |_| cursor_signal.get().is_some());
|
||||
let has_more_signal = Memo::new(move |_| cursor_signal.get().is_some());
|
||||
|
||||
// Fetch initial messages for a guild
|
||||
let fetch_messages_impl = Arc::new(move |guild_id: String| {
|
||||
|
||||
@@ -6,51 +6,82 @@ use wasm_bindgen_futures::spawn_local;
|
||||
pub mod components;
|
||||
pub mod hooks;
|
||||
|
||||
use components::message_feed::MessageFeed;
|
||||
use components::image_grid::ImageGrid;
|
||||
use components::message_feed::MessageFeed;
|
||||
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 }
|
||||
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 (search_query, set_search_query) = signal(String::new());
|
||||
let (search_results, set_search_results) = signal::<Vec<MessageRecord>>(Vec::new());
|
||||
let (show_search, set_show_search) = signal(false);
|
||||
let (is_searching, set_is_searching) = 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);
|
||||
let (retrying_all, set_retrying_all) = 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 stats = Memo::new(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 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 filtered_messages = Memo::new(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()
|
||||
if filter == "all" {
|
||||
return base;
|
||||
}
|
||||
base.into_iter()
|
||||
.filter(|m| {
|
||||
let status = m.ai_status.clone().unwrap_or(AiStatus::Pending);
|
||||
if filter == "analyzed" {
|
||||
return status != AiStatus::Pending;
|
||||
}
|
||||
if filter == "pending" {
|
||||
return status == AiStatus::Pending;
|
||||
}
|
||||
format!("{:?}", status).to_lowercase() == filter
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
// Search handler - takes any event type and triggers the search
|
||||
@@ -90,16 +121,6 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
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;
|
||||
@@ -141,7 +162,7 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
}
|
||||
|
||||
// Fetch messages on mount if guild is configured
|
||||
create_effect(move |_| {
|
||||
Effect::new(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());
|
||||
@@ -174,9 +195,9 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
</div>
|
||||
|
||||
{/* Stats badges */}
|
||||
{(total() > 0).then(|| view! {
|
||||
{move || (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-outline text-xs">{total()} " total" {state.has_more.get().then_some("+")}</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>
|
||||
@@ -194,7 +215,7 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
<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>
|
||||
<svg width="16" height="16" style="position:absolute;left:0.75rem;top:50%;transform:translateY(-50%);color:var(--color-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"
|
||||
@@ -214,42 +235,49 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
>
|
||||
{move || if is_searching.get() { "Searching..." } else { "Search" }}
|
||||
</button>
|
||||
{show_search.get().then(|| view! {
|
||||
{move || 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=move || 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">
|
||||
{move || {
|
||||
(error() > 0 && !show_search.get()).then(|| {
|
||||
let cb = state.reanalyze_all_errors.clone();
|
||||
let err_count = error();
|
||||
view! {
|
||||
<button
|
||||
class="btn btn-destructive btn-sm"
|
||||
on:click=move |_| {
|
||||
set_retrying_all.set(true);
|
||||
let cb = cb.clone();
|
||||
spawn_local(async move {
|
||||
cb();
|
||||
set_retrying_all.set(false);
|
||||
});
|
||||
}
|
||||
disabled=move || 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 ({})", err_count) }}
|
||||
</button>
|
||||
}
|
||||
})
|
||||
}}
|
||||
<div class="ml-auto flex items-center" style="gap:0.375rem">
|
||||
{/* 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>
|
||||
<svg width="16" height="16" style="color:var(--color-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>
|
||||
<button class="filter-chip" class:active=move || ai_filter.get() == f_ptr on:click=move |_| set_filter(f_ptr) >{*f}</button>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search results count */}
|
||||
{show_search.get().then(|| {
|
||||
{move || show_search.get().then(|| {
|
||||
let n = search_results.get().len();
|
||||
view! {
|
||||
<div class="text-sm text-secondary">
|
||||
@@ -283,7 +311,7 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
</div>
|
||||
|
||||
<div class="tab-content" style:display=move || if view_tab.get() == ViewTab::All { "block" } else { "none" }>
|
||||
{
|
||||
{move || {
|
||||
let load_more_cb = state.load_more.clone();
|
||||
let empty_text: &'static str = if show_search.get() { "No messages found matching your search." } else { "No captures yet." };
|
||||
let has_more = if show_search.get() { false } else { state.has_more.get() };
|
||||
@@ -299,10 +327,12 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
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() />
|
||||
{move || view! {
|
||||
<ImageGrid messages=filtered_messages.get() />
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ enum ChatRole {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ChatMessage {
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
role: ChatRole,
|
||||
content: String,
|
||||
@@ -23,21 +24,25 @@ pub fn MascotChatbot() -> impl IntoView {
|
||||
let messages = RwSignal::new(vec![ChatMessage {
|
||||
id: "init-1".to_string(),
|
||||
role: ChatRole::Mascot,
|
||||
content: "Halo! 👋 Aku mascot IMPHNEN. Tanya aku soal analytics, pesan, atau moderation queue.".to_string(),
|
||||
content:
|
||||
"Halo! 👋 Aku mascot IMPHNEN. Tanya aku soal analytics, pesan, atau moderation queue."
|
||||
.to_string(),
|
||||
}]);
|
||||
|
||||
let send_message = move || {
|
||||
let text = input.get().trim().to_string();
|
||||
if text.is_empty() || loading.get() {
|
||||
let text = input.get_untracked().trim().to_string();
|
||||
if text.is_empty() || loading.get_untracked() {
|
||||
return;
|
||||
}
|
||||
|
||||
let now = js_sys::Date::now() as u64;
|
||||
messages.update(|list| list.push(ChatMessage {
|
||||
id: format!("user-{}", now),
|
||||
role: ChatRole::User,
|
||||
content: text.clone(),
|
||||
}));
|
||||
messages.update(|list| {
|
||||
list.push(ChatMessage {
|
||||
id: format!("user-{}", now),
|
||||
role: ChatRole::User,
|
||||
content: text.clone(),
|
||||
})
|
||||
});
|
||||
input.set(String::new());
|
||||
loading.set(true);
|
||||
|
||||
@@ -47,11 +52,13 @@ pub fn MascotChatbot() -> impl IntoView {
|
||||
Err(_) => fallback_response(&text),
|
||||
};
|
||||
|
||||
messages.update(|list| list.push(ChatMessage {
|
||||
id: format!("mascot-{}", js_sys::Date::now() as u64),
|
||||
role: ChatRole::Mascot,
|
||||
content: response,
|
||||
}));
|
||||
messages.update(|list| {
|
||||
list.push(ChatMessage {
|
||||
id: format!("mascot-{}", js_sys::Date::now() as u64),
|
||||
role: ChatRole::Mascot,
|
||||
content: response,
|
||||
})
|
||||
});
|
||||
loading.set(false);
|
||||
});
|
||||
};
|
||||
@@ -143,9 +150,11 @@ fn fallback_response(input: &str) -> String {
|
||||
} else if lower.contains("pesan") || lower.contains("message") {
|
||||
"Cek tab Messages untuk live capture dan hasil AI moderation terbaru.".to_string()
|
||||
} else if lower.contains("voice") || lower.contains("audio") {
|
||||
"Tab Voice & Media punya voice bridge, speakers, media controls, dan recordings.".to_string()
|
||||
"Tab Voice & Media punya voice bridge, speakers, media controls, dan recordings."
|
||||
.to_string()
|
||||
} else if lower.contains("dashboard") || lower.contains("stat") {
|
||||
"Dashboard Guild merangkum total pesan, user aktif, channel teratas, dan moderation queue.".to_string()
|
||||
"Dashboard Guild merangkum total pesan, user aktif, channel teratas, dan moderation queue."
|
||||
.to_string()
|
||||
} else {
|
||||
format!("Menarik: \"{}\". Kalau backend mascot offline, aku tetap bisa bantu arahkan ke Messages, Voice, atau Dashboard. 😊", input)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::features::polish::{persist_theme, ThemeContext};
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn ThemeToggle() -> impl IntoView {
|
||||
@@ -16,7 +16,11 @@ pub fn ThemeToggle() -> impl IntoView {
|
||||
|
||||
let toggle = move |_| {
|
||||
if let Some(ctx) = theme_for_toggle.as_ref() {
|
||||
let next = if ctx.theme.get() == "dark" { "light" } else { "dark" };
|
||||
let next = if ctx.theme.get() == "dark" {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
ctx.theme.set(next.to_string());
|
||||
persist_theme(next);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ pub fn initial_theme() -> String {
|
||||
}
|
||||
|
||||
pub fn persist_theme(theme: &str) {
|
||||
if let Some(storage) = web_sys::window().and_then(|window| window.local_storage().ok().flatten()) {
|
||||
if let Some(storage) =
|
||||
web_sys::window().and_then(|window| window.local_storage().ok().flatten())
|
||||
{
|
||||
let _ = storage.set_item("imphnen-theme", theme);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
// services/frontend-leptos/frontend/src/layout/dashboard_layout.rs
|
||||
use leptos::children::Children;
|
||||
use leptos::prelude::*;
|
||||
use super::header::Header;
|
||||
use super::mobile_tab_bar::MobileTabBar;
|
||||
use super::sidebar::Sidebar;
|
||||
use super::tab_strip::TabStrip;
|
||||
use leptos::children::Children;
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn DashboardLayout(
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
pub fn DashboardLayout(children: Children) -> impl IntoView {
|
||||
view! {
|
||||
<div style="display: flex; flex-direction: column; height: 100vh;">
|
||||
<Header />
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// services/frontend-leptos/frontend/src/layout/header.rs
|
||||
use leptos::prelude::*;
|
||||
use crate::ws::context::WsContext;
|
||||
use crate::ws::socket::WsStatus;
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Header() -> impl IntoView {
|
||||
let ws = use_context::<WsContext>().expect("WsContext not provided");
|
||||
let ws_status = ws.status;
|
||||
|
||||
let indicator_text_memo = create_memo(move |_| match ws_status.get() {
|
||||
let indicator_text_memo = Memo::new(move |_| match ws_status.get() {
|
||||
WsStatus::Connected => "Online",
|
||||
WsStatus::Connecting => "Menghubungkan...",
|
||||
WsStatus::Disconnected => "Offline",
|
||||
WsStatus::Error(_) => "Error",
|
||||
});
|
||||
let indicator_color_memo = create_memo(move |_| match ws_status.get() {
|
||||
let indicator_color_memo = Memo::new(move |_| match ws_status.get() {
|
||||
WsStatus::Connected => "var(--color-success)",
|
||||
WsStatus::Connecting => "var(--color-warning)",
|
||||
WsStatus::Disconnected => "var(--text-tertiary)",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs
|
||||
use crate::app::UiContext;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
use crate::app::UiContext;
|
||||
|
||||
#[component]
|
||||
pub fn MobileTabBar() -> impl IntoView {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// services/frontend-leptos/frontend/src/layout/sidebar.rs
|
||||
use crate::app::UiContext;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
use crate::app::UiContext;
|
||||
|
||||
#[component]
|
||||
pub fn Sidebar() -> impl IntoView {
|
||||
let ui = use_context::<UiContext>().expect("UiContext not provided");
|
||||
let (collapsed, _set_collapsed) = create_signal(false);
|
||||
let (collapsed, _set_collapsed) = signal(false);
|
||||
|
||||
view! {
|
||||
<nav style:width=move || if collapsed.get() { "var(--sidebar-collapsed-width)" } else { "var(--sidebar-width)" }
|
||||
@@ -43,16 +43,12 @@ pub fn Sidebar() -> impl IntoView {
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn NavItem(
|
||||
icon: &'static str,
|
||||
label: &'static str,
|
||||
tab: Tab,
|
||||
ui: UiContext,
|
||||
) -> impl IntoView {
|
||||
fn NavItem(icon: &'static str, label: &'static str, tab: Tab, ui: UiContext) -> impl IntoView {
|
||||
let tab_bg = tab.clone();
|
||||
let tab_clr = tab.clone();
|
||||
let tab_click = tab;
|
||||
let handle_click = move |_| ui.active_tab.set(tab_click.clone());
|
||||
let _ = icon;
|
||||
|
||||
view! {
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// services/frontend-leptos/frontend/src/layout/tab_strip.rs
|
||||
use crate::app::UiContext;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
use crate::app::UiContext;
|
||||
|
||||
#[component]
|
||||
pub fn TabStrip() -> impl IntoView {
|
||||
@@ -22,11 +22,7 @@ pub fn TabStrip() -> impl IntoView {
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TabItem(
|
||||
label: &'static str,
|
||||
tab: Tab,
|
||||
ui: UiContext,
|
||||
) -> impl IntoView {
|
||||
fn TabItem(label: &'static str, tab: Tab, ui: UiContext) -> impl IntoView {
|
||||
let tab_color = tab.clone();
|
||||
let tab_border = tab.clone();
|
||||
let tab_click = tab;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Default)]
|
||||
pub enum BadgeVariant {
|
||||
#[default]
|
||||
Default,
|
||||
Primary,
|
||||
Success,
|
||||
@@ -11,17 +12,8 @@ pub enum BadgeVariant {
|
||||
Info,
|
||||
}
|
||||
|
||||
impl Default for BadgeVariant {
|
||||
fn default() -> Self {
|
||||
Self::Default
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Badge(
|
||||
#[prop(optional)] variant: BadgeVariant,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
pub fn Badge(#[prop(optional)] variant: BadgeVariant, children: Children) -> impl IntoView {
|
||||
let variant_class = match variant {
|
||||
BadgeVariant::Default => "",
|
||||
BadgeVariant::Primary => "badge-primary",
|
||||
|
||||
@@ -12,8 +12,9 @@ pub enum ButtonVariant {
|
||||
Link,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Default)]
|
||||
pub enum ButtonSize {
|
||||
#[default]
|
||||
Default,
|
||||
Sm,
|
||||
Lg,
|
||||
@@ -21,12 +22,6 @@ pub enum ButtonSize {
|
||||
IconSm,
|
||||
}
|
||||
|
||||
impl Default for ButtonSize {
|
||||
fn default() -> Self {
|
||||
Self::Default
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Button(
|
||||
#[prop(optional)] variant: ButtonVariant,
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
pub mod badge;
|
||||
pub mod button;
|
||||
pub mod card;
|
||||
pub mod empty_state;
|
||||
pub mod input;
|
||||
pub mod modal;
|
||||
pub mod scroll_area;
|
||||
pub mod select;
|
||||
pub mod tabs;
|
||||
pub mod toast;
|
||||
pub mod skeleton;
|
||||
pub mod status_badge;
|
||||
pub mod empty_state;
|
||||
pub mod modal;
|
||||
pub mod tabs;
|
||||
pub mod toast;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// services/frontend-leptos/frontend/src/ui/modal.rs
|
||||
use std::sync::Arc;
|
||||
use leptos::prelude::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[component]
|
||||
pub fn Modal(
|
||||
|
||||
@@ -7,6 +7,7 @@ pub fn Tabs(
|
||||
#[prop(optional)] class: &'static str,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
let _ = active;
|
||||
view! {
|
||||
<div class={if !class.is_empty() { format!("tabs {}", class) } else { "tabs".to_string() }}>
|
||||
{children()}
|
||||
@@ -15,10 +16,7 @@ pub fn Tabs(
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TabList(
|
||||
#[prop(optional)] class: &'static str,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
pub fn TabList(#[prop(optional)] class: &'static str, children: Children) -> impl IntoView {
|
||||
view! {
|
||||
<div class={if !class.is_empty() { format!("tab-list {}", class) } else { "tab-list".to_string() }} role="tablist">
|
||||
{children()}
|
||||
@@ -27,11 +25,7 @@ pub fn TabList(
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TabTrigger(
|
||||
value: String,
|
||||
active: RwSignal<String>,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
pub fn TabTrigger(value: String, active: RwSignal<String>, children: Children) -> impl IntoView {
|
||||
let v1 = value.clone();
|
||||
let v2 = value.clone();
|
||||
view! {
|
||||
@@ -48,11 +42,7 @@ pub fn TabTrigger(
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TabContent(
|
||||
value: String,
|
||||
active: RwSignal<String>,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
pub fn TabContent(value: String, active: RwSignal<String>, children: Children) -> impl IntoView {
|
||||
let is_selected = move || active.get() == value;
|
||||
view! {
|
||||
<div
|
||||
|
||||
@@ -23,10 +23,16 @@ pub struct ToastContext {
|
||||
next_id: Arc<Mutex<u64>>,
|
||||
}
|
||||
|
||||
impl Default for ToastContext {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToastContext {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
toasts: create_rw_signal(vec![]),
|
||||
toasts: RwSignal::new(vec![]),
|
||||
next_id: Arc::new(Mutex::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// services/frontend-leptos/frontend/src/ws/context.rs
|
||||
use crate::ws::socket::{WsEvent, WsHandle, WsStatus};
|
||||
use leptos::prelude::*;
|
||||
use crate::ws::socket::{WsHandle, WsStatus, WsEvent};
|
||||
use shared_types::message::MessageRecord;
|
||||
use shared_types::voice::ActiveSpeaker;
|
||||
use shared_types::media::MediaState;
|
||||
use shared_types::message::MessageRecord;
|
||||
use shared_types::recording::VoiceRecording;
|
||||
use shared_types::voice::ActiveSpeaker;
|
||||
|
||||
#[derive(Clone)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct WsContext {
|
||||
pub handle: std::rc::Rc<WsHandle>,
|
||||
pub status: ReadSignal<WsStatus>,
|
||||
@@ -17,7 +18,8 @@ pub struct WsContext {
|
||||
pub on_message_deleted: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(String)>>>>,
|
||||
pub on_message_analyzed: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(MessageRecord)>>>>,
|
||||
pub on_voice_active_user: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(ActiveSpeaker)>>>>,
|
||||
pub on_voice_recording_uploaded: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(VoiceRecording)>>>>,
|
||||
pub on_voice_recording_uploaded:
|
||||
std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(VoiceRecording)>>>>,
|
||||
pub on_media_state: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(MediaState)>>>>,
|
||||
pub on_binary: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(Vec<u8>)>>>>,
|
||||
}
|
||||
@@ -59,14 +61,18 @@ impl WsContext {
|
||||
|
||||
match event_type.as_str() {
|
||||
"message_created" => {
|
||||
if let Some(d) = data.and_then(|v| serde_json::from_value::<MessageRecord>(v.clone()).ok()) {
|
||||
if let Some(d) = data.and_then(|v| {
|
||||
serde_json::from_value::<MessageRecord>(v.clone()).ok()
|
||||
}) {
|
||||
if let Some(cb) = self.on_message_created.borrow().as_ref() {
|
||||
cb(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
"message_updated" => {
|
||||
if let Some(d) = data.and_then(|v| serde_json::from_value::<MessageRecord>(v.clone()).ok()) {
|
||||
if let Some(d) = data.and_then(|v| {
|
||||
serde_json::from_value::<MessageRecord>(v.clone()).ok()
|
||||
}) {
|
||||
if let Some(cb) = self.on_message_updated.borrow().as_ref() {
|
||||
cb(d);
|
||||
}
|
||||
@@ -80,32 +86,39 @@ impl WsContext {
|
||||
}
|
||||
}
|
||||
"message_analyzed" => {
|
||||
if let Some(d) = data.and_then(|v| serde_json::from_value::<MessageRecord>(v.clone()).ok()) {
|
||||
if let Some(d) = data.and_then(|v| {
|
||||
serde_json::from_value::<MessageRecord>(v.clone()).ok()
|
||||
}) {
|
||||
if let Some(cb) = self.on_message_analyzed.borrow().as_ref() {
|
||||
cb(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
"voice_active_user" => {
|
||||
if let Some(d) = data.and_then(|v| serde_json::from_value::<ActiveSpeaker>(v.clone()).ok()) {
|
||||
if let Some(d) = data.and_then(|v| {
|
||||
serde_json::from_value::<ActiveSpeaker>(v.clone()).ok()
|
||||
}) {
|
||||
if let Some(cb) = self.on_voice_active_user.borrow().as_ref() {
|
||||
cb(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
"voice_recording_uploaded" => {
|
||||
if let Some(d) = data.and_then(|v| serde_json::from_value::<VoiceRecording>(v.clone()).ok()) {
|
||||
if let Some(cb) = self.on_voice_recording_uploaded.borrow().as_ref() {
|
||||
if let Some(d) = data.and_then(|v| {
|
||||
serde_json::from_value::<VoiceRecording>(v.clone()).ok()
|
||||
}) {
|
||||
if let Some(cb) = self.on_voice_recording_uploaded.borrow().as_ref()
|
||||
{
|
||||
cb(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
"media_state" => {
|
||||
// Backend sends initial state with "state" key, live updates with "data"
|
||||
let raw = data
|
||||
.or_else(|| parsed.get("state"))
|
||||
.cloned();
|
||||
if let Some(d) = raw.and_then(|v| serde_json::from_value::<MediaState>(v).ok()) {
|
||||
let raw = data.or_else(|| parsed.get("state")).cloned();
|
||||
if let Some(d) =
|
||||
raw.and_then(|v| serde_json::from_value::<MediaState>(v).ok())
|
||||
{
|
||||
if let Some(cb) = self.on_media_state.borrow().as_ref() {
|
||||
cb(d);
|
||||
}
|
||||
@@ -113,7 +126,9 @@ impl WsContext {
|
||||
}
|
||||
_ => {
|
||||
// Unknown event type — log and ignore
|
||||
web_sys::console::log_1(&format!("[WS] unhandled event: {}", event_type).into());
|
||||
web_sys::console::log_1(
|
||||
&format!("[WS] unhandled event: {}", event_type).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// services/frontend-leptos/frontend/src/ws/mod.rs
|
||||
pub mod socket;
|
||||
pub mod context;
|
||||
pub mod socket;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{WebSocket, MessageEvent, CloseEvent, ErrorEvent};
|
||||
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum WsStatus {
|
||||
@@ -18,6 +18,7 @@ pub enum WsEvent {
|
||||
Binary(Vec<u8>),
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct WsHandle {
|
||||
pub status: ReadSignal<WsStatus>,
|
||||
set_status: WriteSignal<WsStatus>,
|
||||
@@ -29,7 +30,7 @@ pub struct WsHandle {
|
||||
|
||||
impl WsHandle {
|
||||
pub fn new(url: &str) -> Self {
|
||||
let (status, set_status) = create_signal(WsStatus::Disconnected);
|
||||
let (status, set_status) = signal(WsStatus::Disconnected);
|
||||
Self {
|
||||
status,
|
||||
set_status,
|
||||
@@ -48,24 +49,34 @@ impl WsHandle {
|
||||
}
|
||||
|
||||
pub fn connect(&self) {
|
||||
if self.status.get() == WsStatus::Connected || self.status.get() == WsStatus::Connecting {
|
||||
if self.status.get_untracked() == WsStatus::Connected
|
||||
|| self.status.get_untracked() == WsStatus::Connecting
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.set_status.set(WsStatus::Connecting);
|
||||
|
||||
let url = self.url.clone();
|
||||
let status_clone = self.set_status.clone();
|
||||
let event_clone: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(WsEvent)>>>> = self.on_event.clone();
|
||||
let status_clone = self.set_status;
|
||||
#[allow(clippy::type_complexity)]
|
||||
let event_clone: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(WsEvent)>>>> =
|
||||
self.on_event.clone();
|
||||
let ws_holder = &self.ws as *const std::cell::RefCell<Option<WebSocket>>;
|
||||
let reconnect_attempt = &self.reconnect_attempt as *const std::cell::Cell<u32>;
|
||||
|
||||
Self::perform_connect(&url, status_clone, event_clone, ws_holder, reconnect_attempt);
|
||||
Self::perform_connect(
|
||||
&url,
|
||||
status_clone,
|
||||
event_clone,
|
||||
ws_holder,
|
||||
reconnect_attempt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared connection setup used for both initial connect and reconnection.
|
||||
/// Takes raw pointers because it must be callable from `wasm_bindgen` closures
|
||||
/// that cannot borrow `self`.
|
||||
#[allow(unsafe_code)]
|
||||
#[allow(unsafe_code, clippy::type_complexity)]
|
||||
fn perform_connect(
|
||||
url: &str,
|
||||
set_status: WriteSignal<WsStatus>,
|
||||
@@ -74,9 +85,9 @@ impl WsHandle {
|
||||
reconnect_attempt: *const std::cell::Cell<u32>,
|
||||
) {
|
||||
let url_owned = url.to_string();
|
||||
let status1 = set_status.clone();
|
||||
let status2 = set_status.clone();
|
||||
let status3 = set_status.clone();
|
||||
let status1 = set_status;
|
||||
let status2 = set_status;
|
||||
let status3 = set_status;
|
||||
let event_clone = on_event.clone();
|
||||
|
||||
match WebSocket::new(&url_owned) {
|
||||
@@ -100,7 +111,9 @@ impl WsHandle {
|
||||
|
||||
let attempt = unsafe { (*reconnect_attempt).get() };
|
||||
if attempt >= 20 {
|
||||
status2.set(WsStatus::Error("Max reconnect attempts reached".to_string()));
|
||||
status2.set(WsStatus::Error(
|
||||
"Max reconnect attempts reached".to_string(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
// Full-jitter exponential backoff: min(1000 * 2^attempt, 30000) * (0.5 + random * 0.5)
|
||||
@@ -110,18 +123,24 @@ impl WsHandle {
|
||||
unsafe { (*reconnect_attempt).set(attempt + 1) };
|
||||
|
||||
let url_reconnect = url_owned.clone();
|
||||
let status_rc = status2.clone();
|
||||
let status_rc = status2;
|
||||
let event_rc = event_for_close.clone();
|
||||
let reconnect_fn = Closure::<dyn Fn()>::new(move || {
|
||||
Self::perform_connect(&url_reconnect, status_rc.clone(), event_rc.clone(), ws_holder, reconnect_attempt);
|
||||
Self::perform_connect(
|
||||
&url_reconnect,
|
||||
status_rc,
|
||||
event_rc.clone(),
|
||||
ws_holder,
|
||||
reconnect_attempt,
|
||||
);
|
||||
});
|
||||
web_sys::window().and_then(|w| {
|
||||
w.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
reconnect_fn.as_ref().unchecked_ref(),
|
||||
delay_ms as i32,
|
||||
)
|
||||
.ok()
|
||||
});
|
||||
web_sys::window()
|
||||
.and_then(|w| {
|
||||
w.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
reconnect_fn.as_ref().unchecked_ref(),
|
||||
delay_ms as i32,
|
||||
).ok()
|
||||
});
|
||||
reconnect_fn.forget();
|
||||
});
|
||||
ws.set_onclose(Some(onclose_cb.as_ref().unchecked_ref()));
|
||||
@@ -155,7 +174,10 @@ impl WsHandle {
|
||||
}
|
||||
Err(e) => {
|
||||
set_status.set(WsStatus::Error(
|
||||
js_sys::Error::from(e).to_string().as_string().unwrap_or_default(),
|
||||
js_sys::Error::from(e)
|
||||
.to_string()
|
||||
.as_string()
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user