fix(fe): perbaiki infinite loop dashboard, config fetching, infinite scroll, dan visualizer reactivity
- Dashboard: ganti Effect::new dengan spawn_local untuk initial fetch agar tidak terjadi infinite loop karena reactive dependency tracking - Config: tambah api/config.rs dan jadikan AppConfig.monitor_guild_id RwSignal supaya di-fetch dari backend (saat startup & setelah login) - Messages: perbaiki messageFetch agar guild_id terbaca dari config reaktif - Infinite scroll: tambah observer_ready signal + spawn_local trigger agar IntersectionObserver setup setelah DOM mount; tambah fallback button - AudioVisualizer & MicLevelMeter: tambah periodic tick signal (100ms) agar efek re-run dan update bars/level dari shared PCM buffer - pcm_decoder: ganti js_sys::eval dengan wasm-bindgen binding langsung ke btoa
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppConfigResponse {
|
||||
pub monitor_guild_id: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/config
|
||||
pub async fn get_config() -> Result<AppConfigResponse, ApiError> {
|
||||
request("GET", "/api/config", None).await
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod dashboard;
|
||||
pub mod mascot;
|
||||
pub mod messages;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::api::config as config_api;
|
||||
use crate::features::dashboard::DashboardPanel;
|
||||
use crate::features::live::LivePanel;
|
||||
use crate::features::messages::MessagesPanel;
|
||||
@@ -6,6 +7,7 @@ use crate::features::polish::{initial_theme, ThemeContext};
|
||||
use crate::ws::context::WsContext;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
/// Derive WebSocket URL from the page's own origin.
|
||||
/// In development (serve on :8080, backend on :3001) use the detected host + /ws path.
|
||||
@@ -28,7 +30,7 @@ fn get_ws_url() -> String {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppConfig {
|
||||
pub monitor_guild_id: Option<String>,
|
||||
pub monitor_guild_id: RwSignal<Option<String>>,
|
||||
}
|
||||
|
||||
// ── Contexts ────────────────────────────────────────────
|
||||
@@ -67,15 +69,53 @@ pub fn App() -> impl IntoView {
|
||||
provide_context(theme.clone());
|
||||
|
||||
let config = AppConfig {
|
||||
monitor_guild_id: None,
|
||||
monitor_guild_id: RwSignal::new(None),
|
||||
};
|
||||
provide_context(config);
|
||||
provide_context(config.clone());
|
||||
|
||||
let ws = WsContext::new(&get_ws_url());
|
||||
provide_context(ws.clone());
|
||||
|
||||
ws.connect();
|
||||
|
||||
// Try to fetch config on startup (works if password is already in localStorage)
|
||||
spawn_local({
|
||||
let config = config.clone();
|
||||
async move {
|
||||
match config_api::get_config().await {
|
||||
Ok(cfg) => {
|
||||
config.monitor_guild_id.set(cfg.monitor_guild_id);
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::log_1(
|
||||
&format!("[config] failed to fetch: {}", e).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Re-fetch config when user authenticates (handles first-time login)
|
||||
Effect::new(move |_| {
|
||||
if auth.authenticated.get() {
|
||||
spawn_local({
|
||||
let config = config.clone();
|
||||
async move {
|
||||
match config_api::get_config().await {
|
||||
Ok(cfg) => {
|
||||
config.monitor_guild_id.set(cfg.monitor_guild_id);
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::log_1(
|
||||
&format!("[config] fetch after auth failed: {}", e).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div data-theme=move || theme.theme.get()>
|
||||
<ParticleBackground />
|
||||
|
||||
@@ -114,11 +114,12 @@ pub fn DashboardPanel() -> impl IntoView {
|
||||
});
|
||||
});
|
||||
|
||||
// Initial fetch on mount (use spawn_local to avoid reactive dependency tracking)
|
||||
{
|
||||
let fetch_stats = fetch_stats.clone();
|
||||
let fetch_users = fetch_users.clone();
|
||||
let fetch_channels = fetch_channels.clone();
|
||||
Effect::new(move |_| {
|
||||
spawn_local(async move {
|
||||
fetch_stats();
|
||||
fetch_users(true);
|
||||
fetch_channels(true);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// PCM Frame decoded from binary WebSocket data
|
||||
/// Format: [u32 userId (4 bytes)][i16 samples (N bytes)]
|
||||
pub struct PcmFrame {
|
||||
@@ -53,15 +55,16 @@ pub fn encode_samples_to_base64(samples: &[f32]) -> String {
|
||||
encode_bytes_base64(&bytes)
|
||||
}
|
||||
|
||||
/// Encode raw bytes to base64 using JavaScript's btoa
|
||||
/// Encode raw bytes to base64 using JavaScript's btoa via wasm-bindgen
|
||||
fn encode_bytes_base64(data: &[u8]) -> String {
|
||||
// Build binary string for btoa
|
||||
let binary: String = data.iter().map(|&b| b as char).collect();
|
||||
// Convert bytes 0-255 to a Latin-1 string (each byte → char with same codepoint)
|
||||
let latin1: String = data.iter().map(|&b| b as char).collect();
|
||||
js_btoa(&latin1)
|
||||
}
|
||||
|
||||
// Call btoa from JavaScript via js_sys::eval
|
||||
let js_code = format!("btoa('{}')", binary.replace('\'', "\\'"));
|
||||
js_sys::eval(&js_code)
|
||||
.ok()
|
||||
.and_then(|r| r.as_string())
|
||||
.unwrap_or_default()
|
||||
/// Direct wasm-bindgen binding to the browser's btoa function
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = btoa)]
|
||||
fn js_btoa(input: &str) -> String;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,19 @@ pub fn AudioVisualizer(
|
||||
#[prop(optional)] pcm_data: Option<Arc<Mutex<Vec<f32>>>>,
|
||||
) -> impl IntoView {
|
||||
let bars = RwSignal::new(vec![0.0; 32]);
|
||||
let (tick, set_tick) = signal(0u32);
|
||||
|
||||
// Periodically update bars from PCM data
|
||||
// Drive periodic updates: increment tick every 100ms
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
loop {
|
||||
gloo_timers::future::TimeoutFuture::new(100).await;
|
||||
set_tick.update(|t| *t = t.wrapping_add(1));
|
||||
}
|
||||
});
|
||||
|
||||
// Effect reacts to tick changes, updating bars from PCM data each frame
|
||||
Effect::new(move |_| {
|
||||
tick.get(); // Track — Effect re-runs on each tick (every 100ms)
|
||||
if let Some(ref pcm_arc) = pcm_data {
|
||||
if let Ok(pcm_vec) = pcm_arc.lock() {
|
||||
let computed = compute_frequency_bands(&pcm_vec);
|
||||
|
||||
@@ -11,9 +11,19 @@ pub fn MicLevelMeter(
|
||||
) -> impl IntoView {
|
||||
let level = RwSignal::new(0.0f32);
|
||||
let peak = RwSignal::new(0.0f32);
|
||||
let (tick, set_tick) = signal(0u32);
|
||||
|
||||
// Update level periodically
|
||||
// Drive periodic updates: increment tick every 100ms
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
loop {
|
||||
gloo_timers::future::TimeoutFuture::new(100).await;
|
||||
set_tick.update(|t| *t = t.wrapping_add(1));
|
||||
}
|
||||
});
|
||||
|
||||
// Effect reacts to tick changes, updating level from PCM data each frame
|
||||
Effect::new(move |_| {
|
||||
tick.get(); // Track — Effect re-runs on each tick
|
||||
if !active {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use leptos::prelude::*;
|
||||
use shared_types::message::MessageRecord;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::IntersectionObserver;
|
||||
|
||||
const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000;
|
||||
@@ -40,31 +41,52 @@ pub fn MessageFeed(
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let sentinel_ref = NodeRef::<html::Div>::new();
|
||||
let (_intersecting, _set_intersecting) = signal(false);
|
||||
let (observer_ready, set_observer_ready) = signal(false);
|
||||
|
||||
// Schedule observer setup to run AFTER the DOM is mounted (next microtask).
|
||||
// With has_more, !loading, and messages present the sentinel div will be in the DOM.
|
||||
if !loading && !messages.is_empty() && has_more {
|
||||
wasm_bindgen_futures::spawn_local({
|
||||
let setter = set_observer_ready;
|
||||
async move {
|
||||
setter.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clone before move into Effect closure so it's still available for the view
|
||||
let on_load_more_io = on_load_more.clone();
|
||||
Effect::new(move |_| {
|
||||
let _ = _intersecting.get(); // track signal
|
||||
let _ready = observer_ready.get();
|
||||
if !_ready {
|
||||
return;
|
||||
}
|
||||
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>| {
|
||||
for entry in entries {
|
||||
if let Some(entry) = entry.dyn_ref::<web_sys::IntersectionObserverEntry>() {
|
||||
if entry.is_intersecting() {
|
||||
if let Some(ref cb) = on_load_more {
|
||||
cb();
|
||||
let cb = on_load_more_io.clone();
|
||||
let observer_cb = Closure::<dyn Fn(Vec<JsValue>, IntersectionObserver)>::new(
|
||||
move |entries: Vec<JsValue>, _observer: IntersectionObserver| {
|
||||
for entry in entries {
|
||||
if let Some(entry) =
|
||||
entry.dyn_ref::<web_sys::IntersectionObserverEntry>()
|
||||
{
|
||||
if entry.is_intersecting() {
|
||||
if let Some(ref cb) = cb {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let observer = IntersectionObserver::new(cb.as_ref().unchecked_ref())
|
||||
.expect("IntersectionObserver failed");
|
||||
},
|
||||
);
|
||||
let observer =
|
||||
IntersectionObserver::new(observer_cb.as_ref().unchecked_ref())
|
||||
.expect("IntersectionObserver failed");
|
||||
observer.observe(&node);
|
||||
// Keep closure alive — forget rather than cleanup since observer owns it
|
||||
observer_cb.forget();
|
||||
on_cleanup(move || {
|
||||
observer.disconnect();
|
||||
});
|
||||
// Keep closure alive
|
||||
cb.forget();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -105,15 +127,33 @@ pub fn MessageFeed(
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
|
||||
{/* Infinite scroll sentinel */}
|
||||
{/* Infinite scroll sentinel + fallback load more button */}
|
||||
{has_more_val.then(|| {
|
||||
view! {
|
||||
<div node_ref=sentinel_ref class="h-4">
|
||||
{loading_more_val.then(|| {
|
||||
use super::message_card::MessageCardSkeleton;
|
||||
view! { <MessageCardSkeleton /> }
|
||||
<>
|
||||
<div node_ref=sentinel_ref class="h-4">
|
||||
{loading_more_val.then(|| {
|
||||
use super::message_card::MessageCardSkeleton;
|
||||
view! { <MessageCardSkeleton /> }
|
||||
})}
|
||||
</div>
|
||||
{/* Fallback: visible button in case IntersectionObserver doesn't fire */}
|
||||
{(!loading_more_val).then(|| {
|
||||
let load_more_cb = on_load_more.clone();
|
||||
view! {
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
class="btn btn-outline btn-sm"
|
||||
on:click=move |_| {
|
||||
if let Some(ref cb) = load_more_cb { cb(); }
|
||||
}
|
||||
>
|
||||
"Load more"
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -164,8 +164,9 @@ pub fn MessagesPanel() -> impl IntoView {
|
||||
// Fetch messages on mount if guild is configured
|
||||
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());
|
||||
if let Some(ref guild_id) = config.monitor_guild_id.get() {
|
||||
let gid = guild_id.clone();
|
||||
(state.fetch_messages)(gid);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ impl ToastContext {
|
||||
|
||||
// Auto-dismiss after 4 seconds
|
||||
let toasts = self.toasts;
|
||||
leptos::prelude::set_timeout(
|
||||
let _ = leptos::prelude::set_timeout(
|
||||
move || {
|
||||
toasts.update(|t| t.retain(|m| m.id != id));
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user