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 auth;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod config;
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
pub mod mascot;
|
pub mod mascot;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::api::config as config_api;
|
||||||
use crate::features::dashboard::DashboardPanel;
|
use crate::features::dashboard::DashboardPanel;
|
||||||
use crate::features::live::LivePanel;
|
use crate::features::live::LivePanel;
|
||||||
use crate::features::messages::MessagesPanel;
|
use crate::features::messages::MessagesPanel;
|
||||||
@@ -6,6 +7,7 @@ use crate::features::polish::{initial_theme, ThemeContext};
|
|||||||
use crate::ws::context::WsContext;
|
use crate::ws::context::WsContext;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use shared_types::ui_state::Tab;
|
use shared_types::ui_state::Tab;
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
|
||||||
/// Derive WebSocket URL from the page's own origin.
|
/// Derive WebSocket URL from the page's own origin.
|
||||||
/// In development (serve on :8080, backend on :3001) use the detected host + /ws path.
|
/// 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)]
|
#[derive(Clone)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub monitor_guild_id: Option<String>,
|
pub monitor_guild_id: RwSignal<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Contexts ────────────────────────────────────────────
|
// ── Contexts ────────────────────────────────────────────
|
||||||
@@ -67,15 +69,53 @@ pub fn App() -> impl IntoView {
|
|||||||
provide_context(theme.clone());
|
provide_context(theme.clone());
|
||||||
|
|
||||||
let config = AppConfig {
|
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());
|
let ws = WsContext::new(&get_ws_url());
|
||||||
provide_context(ws.clone());
|
provide_context(ws.clone());
|
||||||
|
|
||||||
ws.connect();
|
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! {
|
view! {
|
||||||
<div data-theme=move || theme.theme.get()>
|
<div data-theme=move || theme.theme.get()>
|
||||||
<ParticleBackground />
|
<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_stats = fetch_stats.clone();
|
||||||
let fetch_users = fetch_users.clone();
|
let fetch_users = fetch_users.clone();
|
||||||
let fetch_channels = fetch_channels.clone();
|
let fetch_channels = fetch_channels.clone();
|
||||||
Effect::new(move |_| {
|
spawn_local(async move {
|
||||||
fetch_stats();
|
fetch_stats();
|
||||||
fetch_users(true);
|
fetch_users(true);
|
||||||
fetch_channels(true);
|
fetch_channels(true);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
/// PCM Frame decoded from binary WebSocket data
|
/// PCM Frame decoded from binary WebSocket data
|
||||||
/// Format: [u32 userId (4 bytes)][i16 samples (N bytes)]
|
/// Format: [u32 userId (4 bytes)][i16 samples (N bytes)]
|
||||||
pub struct PcmFrame {
|
pub struct PcmFrame {
|
||||||
@@ -53,15 +55,16 @@ pub fn encode_samples_to_base64(samples: &[f32]) -> String {
|
|||||||
encode_bytes_base64(&bytes)
|
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 {
|
fn encode_bytes_base64(data: &[u8]) -> String {
|
||||||
// Build binary string for btoa
|
// Convert bytes 0-255 to a Latin-1 string (each byte → char with same codepoint)
|
||||||
let binary: String = data.iter().map(|&b| b as char).collect();
|
let latin1: String = data.iter().map(|&b| b as char).collect();
|
||||||
|
js_btoa(&latin1)
|
||||||
|
}
|
||||||
|
|
||||||
// Call btoa from JavaScript via js_sys::eval
|
/// Direct wasm-bindgen binding to the browser's btoa function
|
||||||
let js_code = format!("btoa('{}')", binary.replace('\'', "\\'"));
|
#[wasm_bindgen]
|
||||||
js_sys::eval(&js_code)
|
extern "C" {
|
||||||
.ok()
|
#[wasm_bindgen(js_name = btoa)]
|
||||||
.and_then(|r| r.as_string())
|
fn js_btoa(input: &str) -> String;
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,19 @@ pub fn AudioVisualizer(
|
|||||||
#[prop(optional)] pcm_data: Option<Arc<Mutex<Vec<f32>>>>,
|
#[prop(optional)] pcm_data: Option<Arc<Mutex<Vec<f32>>>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let bars = RwSignal::new(vec![0.0; 32]);
|
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 |_| {
|
Effect::new(move |_| {
|
||||||
|
tick.get(); // Track — Effect re-runs on each tick (every 100ms)
|
||||||
if let Some(ref pcm_arc) = pcm_data {
|
if let Some(ref pcm_arc) = pcm_data {
|
||||||
if let Ok(pcm_vec) = pcm_arc.lock() {
|
if let Ok(pcm_vec) = pcm_arc.lock() {
|
||||||
let computed = compute_frequency_bands(&pcm_vec);
|
let computed = compute_frequency_bands(&pcm_vec);
|
||||||
|
|||||||
@@ -11,9 +11,19 @@ pub fn MicLevelMeter(
|
|||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let level = RwSignal::new(0.0f32);
|
let level = RwSignal::new(0.0f32);
|
||||||
let peak = 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 |_| {
|
Effect::new(move |_| {
|
||||||
|
tick.get(); // Track — Effect re-runs on each tick
|
||||||
if !active {
|
if !active {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use leptos::prelude::*;
|
|||||||
use shared_types::message::MessageRecord;
|
use shared_types::message::MessageRecord;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
use web_sys::IntersectionObserver;
|
use web_sys::IntersectionObserver;
|
||||||
|
|
||||||
const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000;
|
const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000;
|
||||||
@@ -40,31 +41,52 @@ pub fn MessageFeed(
|
|||||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let sentinel_ref = NodeRef::<html::Div>::new();
|
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 |_| {
|
Effect::new(move |_| {
|
||||||
let _ = _intersecting.get(); // track signal
|
let _ready = observer_ready.get();
|
||||||
|
if !_ready {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(node) = sentinel_ref.get() {
|
if let Some(node) = sentinel_ref.get() {
|
||||||
let on_load_more = on_load_more.clone();
|
let cb = on_load_more_io.clone();
|
||||||
let cb = Closure::<dyn Fn(Vec<JsValue>)>::new(move |entries: Vec<JsValue>| {
|
let observer_cb = Closure::<dyn Fn(Vec<JsValue>, IntersectionObserver)>::new(
|
||||||
for entry in entries {
|
move |entries: Vec<JsValue>, _observer: IntersectionObserver| {
|
||||||
if let Some(entry) = entry.dyn_ref::<web_sys::IntersectionObserverEntry>() {
|
for entry in entries {
|
||||||
if entry.is_intersecting() {
|
if let Some(entry) =
|
||||||
if let Some(ref cb) = on_load_more {
|
entry.dyn_ref::<web_sys::IntersectionObserverEntry>()
|
||||||
cb();
|
{
|
||||||
|
if entry.is_intersecting() {
|
||||||
|
if let Some(ref cb) = cb {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
);
|
||||||
let observer = IntersectionObserver::new(cb.as_ref().unchecked_ref())
|
let observer =
|
||||||
.expect("IntersectionObserver failed");
|
IntersectionObserver::new(observer_cb.as_ref().unchecked_ref())
|
||||||
|
.expect("IntersectionObserver failed");
|
||||||
observer.observe(&node);
|
observer.observe(&node);
|
||||||
|
// Keep closure alive — forget rather than cleanup since observer owns it
|
||||||
|
observer_cb.forget();
|
||||||
on_cleanup(move || {
|
on_cleanup(move || {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
});
|
});
|
||||||
// Keep closure alive
|
|
||||||
cb.forget();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,15 +127,33 @@ pub fn MessageFeed(
|
|||||||
}
|
}
|
||||||
}).collect::<Vec<_>>()}
|
}).collect::<Vec<_>>()}
|
||||||
|
|
||||||
{/* Infinite scroll sentinel */}
|
{/* Infinite scroll sentinel + fallback load more button */}
|
||||||
{has_more_val.then(|| {
|
{has_more_val.then(|| {
|
||||||
view! {
|
view! {
|
||||||
<div node_ref=sentinel_ref class="h-4">
|
<>
|
||||||
{loading_more_val.then(|| {
|
<div node_ref=sentinel_ref class="h-4">
|
||||||
use super::message_card::MessageCardSkeleton;
|
{loading_more_val.then(|| {
|
||||||
view! { <MessageCardSkeleton /> }
|
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>
|
</div>
|
||||||
|
|||||||
@@ -164,8 +164,9 @@ pub fn MessagesPanel() -> impl IntoView {
|
|||||||
// Fetch messages on mount if guild is configured
|
// Fetch messages on mount if guild is configured
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
if let Some(config) = use_context::<crate::app::AppConfig>() {
|
if let Some(config) = use_context::<crate::app::AppConfig>() {
|
||||||
if let Some(ref guild_id) = config.monitor_guild_id {
|
if let Some(ref guild_id) = config.monitor_guild_id.get() {
|
||||||
(state.fetch_messages)(guild_id.clone());
|
let gid = guild_id.clone();
|
||||||
|
(state.fetch_messages)(gid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ impl ToastContext {
|
|||||||
|
|
||||||
// Auto-dismiss after 4 seconds
|
// Auto-dismiss after 4 seconds
|
||||||
let toasts = self.toasts;
|
let toasts = self.toasts;
|
||||||
leptos::prelude::set_timeout(
|
let _ = leptos::prelude::set_timeout(
|
||||||
move || {
|
move || {
|
||||||
toasts.update(|t| t.retain(|m| m.id != id));
|
toasts.update(|t| t.retain(|m| m.id != id));
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user