Merge branch 'leptos-rewrite'
This commit is contained in:
@@ -13,3 +13,5 @@ logs/
|
||||
.moon/docker
|
||||
worktrees/
|
||||
.worktrees/
|
||||
frontend-leptos/frontend/dist/
|
||||
target/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# API/WS endpoints — set these before building
|
||||
VITE_BE_API_URL=http://localhost:3001
|
||||
VITE_BE_WS_URL=ws://localhost:3001/ws
|
||||
Generated
+2548
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["shared-types", "frontend"]
|
||||
@@ -0,0 +1,63 @@
|
||||
[package]
|
||||
name = "frontend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
leptos = { version = "0.7", features = ["csr"] }
|
||||
leptos-use = "0.14"
|
||||
lucide-leptos = "3"
|
||||
shared-types = { path = "../shared-types" }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"WebSocket",
|
||||
"MessageEvent",
|
||||
"CloseEvent",
|
||||
"ErrorEvent",
|
||||
"CanvasRenderingContext2d",
|
||||
"AudioContext",
|
||||
"AudioBuffer",
|
||||
"AudioBufferSourceNode",
|
||||
"AudioDestinationNode",
|
||||
"AudioNode",
|
||||
"AudioProcessingEvent",
|
||||
"MediaStreamAudioSourceNode",
|
||||
"ScriptProcessorNode",
|
||||
"Window",
|
||||
"Document",
|
||||
"Element",
|
||||
"HtmlElement",
|
||||
"HtmlSelectElement",
|
||||
"KeyboardEvent",
|
||||
"Storage",
|
||||
"IntersectionObserver",
|
||||
"ResizeObserver",
|
||||
"Url",
|
||||
"Headers",
|
||||
"Request",
|
||||
"RequestInit",
|
||||
"RequestMode",
|
||||
"Response",
|
||||
"HtmlInputElement",
|
||||
"HtmlAudioElement",
|
||||
"HtmlCanvasElement",
|
||||
"MediaDevices",
|
||||
"MediaStream",
|
||||
"MediaStreamConstraints",
|
||||
"MediaStreamTrack",
|
||||
"Navigator",
|
||||
"console",
|
||||
] }
|
||||
gloo-net = "0.6"
|
||||
gloo-timers = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
wasm-logger = "0.2"
|
||||
console_error_panic_hook = "0.1"
|
||||
regex = "1"
|
||||
@@ -0,0 +1,7 @@
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "dist"
|
||||
|
||||
[serve]
|
||||
port = 8080
|
||||
open = false
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#23a1eb" />
|
||||
<title>IMPHNEN -- Discord Moderation</title>
|
||||
<link data-trunk rel="rust" data-crate="frontend" data-wasm="frontend.wasm" />
|
||||
<link data-trunk rel="css" href="src/app.css" />
|
||||
<link data-trunk rel="copy-dir" href="public/" />
|
||||
<!-- Poppins font -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<!-- Preload WASM (Trunk inlines this, but just in case) -->
|
||||
<link rel="preload" href="/frontend.wasm" as="fetch" crossorigin="anonymous" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Leptos CSR mounts to document.body by default -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
# Trunk copies this directory to dist/
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LoginPayload {
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginResponse {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
pub async fn login(password: &str) -> Result<bool, ApiError> {
|
||||
let payload = LoginPayload {
|
||||
password: password.to_string(),
|
||||
};
|
||||
let body = serde_json::to_string(&payload).unwrap();
|
||||
let resp: LoginResponse = request("POST", "/api/auth/login", Some(&body)).await?;
|
||||
Ok(resp.ok)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::{Request, RequestInit, RequestMode, Headers, Response};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
pub message: String,
|
||||
pub status_code: u16,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApiError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "API error {}: {}", self.status_code, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ApiError {}
|
||||
|
||||
fn get_base_url() -> String {
|
||||
if let Some(window) = web_sys::window() {
|
||||
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());
|
||||
format!("{}://{}", protocol, host)
|
||||
} else {
|
||||
"http://localhost:3001".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_auth_header() -> Option<String> {
|
||||
// Read password from sessionStorage
|
||||
let storage = web_sys::window()?.local_storage().ok()??;
|
||||
storage.get_item("admin-password").ok()?
|
||||
}
|
||||
|
||||
pub async fn request<T: DeserializeOwned>(
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<&str>,
|
||||
) -> Result<T, ApiError> {
|
||||
let url = format!("{}{}", get_base_url(), path);
|
||||
|
||||
let headers = Headers::new().map_err(|_| ApiError {
|
||||
message: "Failed to create headers".to_string(),
|
||||
status_code: 0,
|
||||
})?;
|
||||
|
||||
if let Some(password) = get_auth_header() {
|
||||
headers.set("X-Admin-Password", &password).ok();
|
||||
}
|
||||
|
||||
if body.is_some() {
|
||||
headers.set("Content-Type", "application/json").ok();
|
||||
}
|
||||
|
||||
let opts = RequestInit::new();
|
||||
opts.set_method(method);
|
||||
opts.set_headers(&headers);
|
||||
opts.set_mode(RequestMode::Cors);
|
||||
|
||||
if let Some(json_body) = body {
|
||||
opts.set_body(&JsValue::from_str(json_body));
|
||||
}
|
||||
|
||||
let request = Request::new_with_str_and_init(&url, &opts).map_err(|e| ApiError {
|
||||
message: format!("Failed to create request: {:?}", e),
|
||||
status_code: 0,
|
||||
})?;
|
||||
|
||||
let window = web_sys::window().ok_or(ApiError {
|
||||
message: "No window".to_string(),
|
||||
status_code: 0,
|
||||
})?;
|
||||
|
||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.map_err(|e| ApiError {
|
||||
message: format!("Fetch failed: {:?}", e),
|
||||
status_code: 0,
|
||||
})?;
|
||||
|
||||
let response: Response = resp_value.dyn_into().map_err(|_| ApiError {
|
||||
message: "Invalid response".to_string(),
|
||||
status_code: 0,
|
||||
})?;
|
||||
|
||||
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,
|
||||
})?
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.as_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
return Err(ApiError {
|
||||
message: text,
|
||||
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(),
|
||||
status_code: status,
|
||||
})?
|
||||
.as_string()
|
||||
.ok_or(ApiError {
|
||||
message: "Response is not text".to_string(),
|
||||
status_code: status,
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&text).map_err(|e| ApiError {
|
||||
message: format!("JSON parse error: {} — body: {}", e, &text[..text.len().min(200)]),
|
||||
status_code: status,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn request_no_body(method: &str, path: &str) -> Result<(), ApiError> {
|
||||
request::<serde_json::Value>(method, path, None).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use shared_types::dashboard::*;
|
||||
|
||||
/// GET /api/dashboard/stats
|
||||
pub async fn get_dashboard_stats() -> Result<DashboardStats, ApiError> {
|
||||
request("GET", "/api/dashboard/stats", None).await
|
||||
}
|
||||
|
||||
/// GET /api/dashboard/users?limit=&cursor=&search=
|
||||
pub async fn get_dashboard_users(
|
||||
limit: Option<u32>,
|
||||
cursor: Option<&str>,
|
||||
search: Option<&str>,
|
||||
) -> 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("&"))); }
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct PaginatedUsers {
|
||||
pub data: Vec<DashboardUser>,
|
||||
#[serde(rename = "nextCursor")]
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/dashboard/users/{userId}
|
||||
pub async fn get_dashboard_user_detail(user_id: &str) -> Result<DashboardUserDetail, ApiError> {
|
||||
request("GET", &format!("/api/dashboard/users/{}", user_id), None).await
|
||||
}
|
||||
|
||||
/// GET /api/dashboard/channels?limit=&cursor=&search=&guild_id=
|
||||
pub async fn get_dashboard_channels(
|
||||
limit: Option<u32>,
|
||||
cursor: Option<&str>,
|
||||
search: Option<&str>,
|
||||
guild_id: Option<&str>,
|
||||
) -> 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("&"))); }
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct PaginatedChannels {
|
||||
pub data: Vec<DashboardChannel>,
|
||||
#[serde(rename = "nextCursor")]
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MascotChatRequest<'a> {
|
||||
message: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MascotChatResponse {
|
||||
pub response: String,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
pub async fn send_mascot_message(message: &str) -> Result<MascotChatResponse, ApiError> {
|
||||
let body = serde_json::to_string(&MascotChatRequest { message }).map_err(|err| ApiError {
|
||||
message: format!("Failed to serialize mascot request: {}", err),
|
||||
status_code: 0,
|
||||
})?;
|
||||
request("POST", "/api/mascot/chat", Some(&body)).await
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use shared_types::message::{MessageRecord, PageResult};
|
||||
|
||||
/// GET /api/messages?guildId=&limit=&channelId=&cursor=
|
||||
pub async fn get_messages(
|
||||
guild_id: &str,
|
||||
limit: Option<u32>,
|
||||
channel_id: Option<&str>,
|
||||
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)); }
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
/// GET /api/review?params
|
||||
pub async fn get_review_messages(
|
||||
guild_id: &str,
|
||||
limit: Option<u32>,
|
||||
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)); }
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
/// GET /api/messages/detail/{id}
|
||||
pub async fn get_message_detail(id: &str) -> Result<Option<MessageRecord>, ApiError> {
|
||||
request("GET", &format!("/api/messages/detail/{}", id), None).await
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST /api/messages/reanalyze-batch
|
||||
pub async fn reanalyze_batch() -> Result<u64, ApiError> {
|
||||
#[derive(serde::Deserialize)]
|
||||
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> {
|
||||
#[derive(serde::Deserialize)]
|
||||
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)); }
|
||||
let resp: SearchResult = request("GET", &path, None).await?;
|
||||
Ok(resp.results)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod client;
|
||||
pub mod auth;
|
||||
pub mod messages;
|
||||
pub mod voice;
|
||||
pub mod dashboard;
|
||||
pub mod mascot;
|
||||
pub mod recordings;
|
||||
@@ -0,0 +1,20 @@
|
||||
use crate::api::client::{request, request_no_body, ApiError};
|
||||
use shared_types::recording::VoiceRecordingListResponse;
|
||||
|
||||
/// GET /api/recordings?limit=&cursor=
|
||||
pub async fn get_recordings(
|
||||
limit: Option<u32>,
|
||||
cursor: Option<&str>,
|
||||
) -> 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("&"))); }
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
/// DELETE /api/recordings/{id}
|
||||
pub async fn delete_recording(id: &str) -> Result<(), ApiError> {
|
||||
request_no_body("DELETE", &format!("/api/recordings/{}", id)).await
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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 serde::Serialize;
|
||||
|
||||
/// GET /api/guilds
|
||||
pub async fn get_guilds() -> Result<Vec<Guild>, ApiError> {
|
||||
request("GET", "/api/guilds", None).await
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// GET /api/guilds/{guildId}/channels
|
||||
pub async fn get_text_channels(guild_id: &str) -> Result<Vec<Channel>, ApiError> {
|
||||
request("GET", &format!("/api/guilds/{}/channels", guild_id), None).await
|
||||
}
|
||||
|
||||
/// GET /api/voice/status
|
||||
pub async fn get_voice_status() -> Result<VoiceStatus, ApiError> {
|
||||
request("GET", "/api/voice/status", None).await
|
||||
}
|
||||
|
||||
/// POST /api/voice/connect { guildId, channelId }
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ConnectPayload {
|
||||
guild_id: String,
|
||||
channel_id: String,
|
||||
}
|
||||
pub async fn connect_voice(guild_id: &str, channel_id: &str) -> Result<VoiceStatus, ApiError> {
|
||||
let body = serde_json::to_string(&ConnectPayload {
|
||||
guild_id: guild_id.to_string(),
|
||||
channel_id: channel_id.to_string(),
|
||||
}).unwrap();
|
||||
request("POST", "/api/voice/connect", Some(&body)).await
|
||||
}
|
||||
|
||||
/// POST /api/voice/disconnect
|
||||
pub async fn disconnect_voice() -> Result<VoiceStatus, ApiError> {
|
||||
request("POST", "/api/voice/disconnect", Some("{}")).await
|
||||
}
|
||||
|
||||
/// GET /api/media/status
|
||||
pub async fn get_media_status() -> Result<MediaState, ApiError> {
|
||||
request("GET", "/api/media/status", None).await
|
||||
}
|
||||
|
||||
/// POST /api/media/queue { source, mode }
|
||||
#[derive(Serialize)]
|
||||
struct MediaQueuePayload {
|
||||
source: String,
|
||||
mode: String,
|
||||
}
|
||||
pub async fn media_queue(source: &str, mode: &str) -> Result<MediaState, ApiError> {
|
||||
let body = serde_json::to_string(&MediaQueuePayload {
|
||||
source: source.to_string(),
|
||||
mode: mode.to_string(),
|
||||
}).unwrap();
|
||||
request("POST", "/api/media/queue", Some(&body)).await
|
||||
}
|
||||
|
||||
/// POST /api/media/skip
|
||||
pub async fn media_skip() -> Result<MediaState, ApiError> {
|
||||
request("POST", "/api/media/skip", Some("{}")).await
|
||||
}
|
||||
|
||||
/// POST /api/media/stop
|
||||
pub async fn media_stop() -> Result<MediaState, ApiError> {
|
||||
request("POST", "/api/media/stop", Some("{}")).await
|
||||
}
|
||||
|
||||
/// POST /api/media/volume { volume }
|
||||
#[derive(Serialize)]
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
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};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppConfig {
|
||||
pub monitor_guild_id: Option<String>,
|
||||
}
|
||||
|
||||
// ── Contexts ────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthContext {
|
||||
pub authenticated: RwSignal<bool>,
|
||||
pub password: RwSignal<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UiContext {
|
||||
pub active_tab: RwSignal<Tab>,
|
||||
pub selected_guild: RwSignal<Option<String>>,
|
||||
}
|
||||
|
||||
// ── App ─────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
// Initialize contexts
|
||||
let auth = AuthContext {
|
||||
authenticated: create_rw_signal(false),
|
||||
password: create_rw_signal(String::new()),
|
||||
};
|
||||
let ui = UiContext {
|
||||
active_tab: create_rw_signal(Tab::Messages),
|
||||
selected_guild: create_rw_signal(None),
|
||||
};
|
||||
let theme = ThemeContext {
|
||||
theme: create_rw_signal(initial_theme()),
|
||||
};
|
||||
|
||||
provide_context(auth.clone());
|
||||
provide_context(ui.clone());
|
||||
provide_context(theme.clone());
|
||||
|
||||
let config = AppConfig {
|
||||
monitor_guild_id: None,
|
||||
};
|
||||
provide_context(config);
|
||||
|
||||
let ws = WsContext::new("ws://localhost:3001/ws");
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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">
|
||||
<div class="app-brand">
|
||||
<span class="app-brand-mark">"IMPHNEN"</span>
|
||||
<span class="app-brand-subtitle">"Discord Moderation"</span>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<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" />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-content">
|
||||
{move || match ui.active_tab.get() {
|
||||
Tab::Messages => view! { <MessagesPanel /> }.into_any(),
|
||||
Tab::Live => view! { <LivePanel /> }.into_any(),
|
||||
Tab::Dashboard => view! { <DashboardPanel /> }.into_any(),
|
||||
}}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{move || auth.authenticated.get().then(|| view! { <MascotChatbot /> })}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab Button Helper ───────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn TabButton(
|
||||
tab: Tab,
|
||||
ui: UiContext,
|
||||
label: &'static str,
|
||||
) -> impl IntoView {
|
||||
let active_tab = ui.active_tab.clone();
|
||||
let tab1 = tab.clone();
|
||||
let tab2 = tab.clone();
|
||||
let tab3 = tab.clone();
|
||||
let tab4 = tab;
|
||||
|
||||
view! {
|
||||
<button
|
||||
class:btn=true
|
||||
class:btn-ghost=true
|
||||
class:btn-active=move || active_tab.get() == tab1
|
||||
on:click=move |_| active_tab.set(tab4.clone())
|
||||
style:background=move || if active_tab.get() == tab2 { "var(--surface-overlay)" } else { "" }
|
||||
style:color=move || if active_tab.get() == tab3 { "var(--color-primary)" } else { "" }
|
||||
style:width="100%"
|
||||
style:justify-content="flex-start"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// services/frontend-leptos/frontend/src/auth.rs
|
||||
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 handle_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
let pwd = password.get();
|
||||
if pwd.is_empty() {
|
||||
set_error.set(Some("Password diperlukan".to_string()));
|
||||
return;
|
||||
}
|
||||
set_loading.set(true);
|
||||
set_error.set(None);
|
||||
|
||||
let auth_clone = auth.clone();
|
||||
let pwd_clone = pwd.clone();
|
||||
let set_loading_clone = set_loading.clone();
|
||||
let set_error_clone = set_error.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
match auth_api::login(&pwd_clone).await {
|
||||
Ok(true) => {
|
||||
// Store password in sessionStorage
|
||||
if let Some(storage) = web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok())
|
||||
.flatten()
|
||||
{
|
||||
let _ = storage.set_item("admin-password", &pwd_clone);
|
||||
}
|
||||
auth_clone.authenticated.set(true);
|
||||
auth_clone.password.set(pwd_clone);
|
||||
}
|
||||
Ok(false) => {
|
||||
set_error_clone.set(Some("Login gagal — password salah".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
set_error_clone.set(Some(format!("Error: {}", e.message)));
|
||||
}
|
||||
}
|
||||
set_loading_clone.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content" style="width: 380px;">
|
||||
<div class="modal-body" style="text-align: center;">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem;">
|
||||
"Akses Dashboard"
|
||||
</h2>
|
||||
<p style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 1.5rem;">
|
||||
"Masukkan password admin untuk melanjutkan"
|
||||
</p>
|
||||
<form on:submit=handle_submit style="display: flex; flex-direction: column; gap: 0.75rem;">
|
||||
<input
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="Password"
|
||||
prop:value=password
|
||||
on:input=move |ev| set_password.set(event_target_value(&ev))
|
||||
/>
|
||||
{move || error.get().map(|e| view! {
|
||||
<p style="color: var(--color-error); font-size: 0.75rem;">{e}</p>
|
||||
})}
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-full btn-lg"
|
||||
disabled=move || loading.get()
|
||||
>
|
||||
{move || if loading.get() { "Memproses..." } else { "Masuk" }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::dashboard::DashboardChannel;
|
||||
|
||||
#[component]
|
||||
pub fn ChannelSummaryList(
|
||||
channels: Vec<DashboardChannel>,
|
||||
loading: bool,
|
||||
error: Option<String>,
|
||||
search: String,
|
||||
has_more: bool,
|
||||
on_search_change: Box<dyn Fn(String) + Send + Sync + 'static>,
|
||||
on_load_more: Box<dyn Fn() + Send + Sync + 'static>,
|
||||
on_retry: Box<dyn Fn() + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let search_cb = StoredValue::new(on_search_change);
|
||||
let load_more_cb = StoredValue::new(on_load_more);
|
||||
let retry_cb = StoredValue::new(on_retry);
|
||||
|
||||
view! {
|
||||
<div class="card dashboard-list-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Kanal"</div>
|
||||
<p class="card-description">"Ringkasan aktivitas, flagged message, dan budaya kanal."</p>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="dashboard-list-toolbar">
|
||||
<input
|
||||
class="input w-full"
|
||||
placeholder="Search channels..."
|
||||
prop:value=search
|
||||
on:input=move |ev| search_cb.with_value(|cb| cb(event_target_value(&ev)))
|
||||
/>
|
||||
</div>
|
||||
|
||||
{move || {
|
||||
if loading && channels.is_empty() {
|
||||
view! { <ListSkeleton /> }.into_any()
|
||||
} else if let Some(err) = error.clone() {
|
||||
view! {
|
||||
<div class="dashboard-list-empty">
|
||||
<div class="text-error text-xl">"⚠"</div>
|
||||
<p class="text-sm text-secondary">{err}</p>
|
||||
<button class="btn btn-outline btn-sm" on:click=move |_| retry_cb.with_value(|cb| cb())>
|
||||
"Retry"
|
||||
</button>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else if channels.is_empty() {
|
||||
view! {
|
||||
<div class="dashboard-list-empty">
|
||||
<div class="text-2xl">"#"</div>
|
||||
<p class="text-sm text-secondary">"No channels found."</p>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="dashboard-summary-list">
|
||||
{channels.clone().into_iter().map(|channel| view! {
|
||||
<ChannelRow channel=channel />
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
}}
|
||||
|
||||
{move || {
|
||||
(has_more && !loading).then(|| view! {
|
||||
<div class="mt-4 text-center">
|
||||
<button class="btn btn-outline btn-sm" on:click=move |_| load_more_cb.with_value(|cb| cb())>
|
||||
"Load more channels"
|
||||
</button>
|
||||
</div>
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ChannelRow(channel: DashboardChannel) -> impl IntoView {
|
||||
let name = channel.channel_name.clone().unwrap_or_else(|| channel.channel_id.clone());
|
||||
let summary = channel
|
||||
.culture_summary
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{} messages", format_number(channel.total_messages)));
|
||||
let last_seen = channel.last_message_at.map(format_timestamp);
|
||||
|
||||
view! {
|
||||
<div class="dashboard-summary-row">
|
||||
<div class="dashboard-summary-avatar dashboard-channel-avatar">
|
||||
<span>"#"</span>
|
||||
</div>
|
||||
<div class="dashboard-summary-main">
|
||||
<div class="dashboard-summary-title">{format!("#{}", name)}</div>
|
||||
<div class="dashboard-summary-text">{summary}</div>
|
||||
<div class="dashboard-summary-meta">
|
||||
<span>{format!("{} messages", format_number(channel.total_messages))}</span>
|
||||
<span>{format!("{} flagged", format_number(channel.flagged_count))}</span>
|
||||
{last_seen.map(|t| view! { <span>{format!("Last: {}", t)}</span> })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ListSkeleton() -> impl IntoView {
|
||||
view! {
|
||||
<div class="dashboard-summary-list">
|
||||
{(0..5).map(|_| view! {
|
||||
<div class="dashboard-summary-row">
|
||||
<div class="skeleton skeleton-circular" style="width:40px;height:40px"></div>
|
||||
<div class="dashboard-summary-main">
|
||||
<div class="skeleton" style="height:16px;width:160px"></div>
|
||||
<div class="skeleton mt-2" style="height:14px;width:240px"></div>
|
||||
</div>
|
||||
</div>
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
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(','); }
|
||||
out.push(ch);
|
||||
}
|
||||
out.chars().rev().collect()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod stats_overview;
|
||||
pub mod user_summary_list;
|
||||
pub mod channel_summary_list;
|
||||
|
||||
pub use stats_overview::StatsOverview;
|
||||
pub use user_summary_list::UserSummaryList;
|
||||
pub use channel_summary_list::ChannelSummaryList;
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::dashboard::{DashboardStats, TopChannel};
|
||||
|
||||
#[component]
|
||||
pub fn StatsOverview(
|
||||
stats: Option<DashboardStats>,
|
||||
loading: bool,
|
||||
error: Option<String>,
|
||||
on_retry: Box<dyn Fn() + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let retry = StoredValue::new(on_retry);
|
||||
|
||||
view! {
|
||||
<div class="dashboard-stats">
|
||||
{move || {
|
||||
if loading {
|
||||
view! { <StatsSkeleton /> }.into_any()
|
||||
} else if let Some(err) = error.clone() {
|
||||
view! {
|
||||
<div class="card p-6 text-center">
|
||||
<div class="text-error text-2xl mb-2">"⚠"</div>
|
||||
<p class="text-sm text-secondary mb-4">{err}</p>
|
||||
<button class="btn btn-outline btn-sm" on:click=move |_| retry.with_value(|cb| cb())>
|
||||
"Retry"
|
||||
</button>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else if let Some(stats) = stats.clone() {
|
||||
view! {
|
||||
<div class="dashboard-stats-grid">
|
||||
<MetricCard label="Total Messages" value=stats.total_messages icon="💬" tone="primary" />
|
||||
<MetricCard label="Today's Messages" value=stats.today_messages icon="📅" tone="success" />
|
||||
<MetricCard label="Total Users" value=stats.total_users icon="👥" tone="primary" />
|
||||
<MetricCard label="Active 24h" value=stats.active_users_24h icon="🟢" tone="success" />
|
||||
<MetricCard label="Flagged" value=stats.total_flagged icon="🚩" tone="error" />
|
||||
<MetricCard label="Clean" value=stats.total_clean icon="✅" tone="success" />
|
||||
<MetricCard label="Voice Recordings" value=stats.total_voice_recordings icon="🎙" tone="info" />
|
||||
<MetricCard label="AI Profiles" value=stats.total_profiles icon="🧠" tone="warning" />
|
||||
|
||||
<div class="card dashboard-wide-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Top Channels"</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<TopChannels channels=stats.top_channels.clone() />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card dashboard-wide-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Moderation Queue"</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="dashboard-moderation-grid">
|
||||
<QueueMetric label="Pending" value=stats.moderation_overview.pending tone="secondary" />
|
||||
<QueueMetric label="Processing" value=stats.moderation_overview.processing tone="warning" />
|
||||
<QueueMetric label="Errors" value=stats.moderation_overview.error tone="error" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="card p-6 text-center">
|
||||
<p class="text-sm text-secondary">"No dashboard data available yet."</p>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
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">
|
||||
<div>
|
||||
<div class="dashboard-metric-label">{label}</div>
|
||||
<div class="dashboard-metric-value">{format_number(value)}</div>
|
||||
</div>
|
||||
<div class=format!("dashboard-metric-icon tone-{}", tone)>{icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn QueueMetric(label: &'static str, value: u64, tone: &'static str) -> impl IntoView {
|
||||
view! {
|
||||
<div class=format!("dashboard-queue-card tone-{}", tone)>
|
||||
<div class="dashboard-queue-value">{format_number(value)}</div>
|
||||
<div class="dashboard-queue-label">{label}</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TopChannels(channels: Vec<TopChannel>) -> impl IntoView {
|
||||
if channels.is_empty() {
|
||||
return view! { <p class="text-sm text-secondary">"No channel data yet."</p> }.into_any();
|
||||
}
|
||||
|
||||
view! {
|
||||
<div class="dashboard-top-channels">
|
||||
{channels.into_iter().map(|ch| {
|
||||
let name = ch.channel_name.unwrap_or_else(|| ch.channel_id.clone());
|
||||
view! {
|
||||
<div class="dashboard-top-channel-row">
|
||||
<span class="truncate">{format!("#{}", name)}</span>
|
||||
<span class="font-semibold">{format_number(ch.message_count)}</span>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn StatsSkeleton() -> impl IntoView {
|
||||
view! {
|
||||
<div class="dashboard-stats-grid">
|
||||
{(0..8).map(|_| view! {
|
||||
<div class="card dashboard-metric-card">
|
||||
<div class="skeleton" style="height:14px;width:96px"></div>
|
||||
<div class="skeleton mt-2" style="height:32px;width:72px"></div>
|
||||
</div>
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
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(',');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out.chars().rev().collect()
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::dashboard::DashboardUser;
|
||||
|
||||
#[component]
|
||||
pub fn UserSummaryList(
|
||||
users: Vec<DashboardUser>,
|
||||
loading: bool,
|
||||
error: Option<String>,
|
||||
search: String,
|
||||
has_more: bool,
|
||||
on_search_change: Box<dyn Fn(String) + Send + Sync + 'static>,
|
||||
on_load_more: Box<dyn Fn() + Send + Sync + 'static>,
|
||||
on_retry: Box<dyn Fn() + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let search_cb = StoredValue::new(on_search_change);
|
||||
let load_more_cb = StoredValue::new(on_load_more);
|
||||
let retry_cb = StoredValue::new(on_retry);
|
||||
|
||||
view! {
|
||||
<div class="card dashboard-list-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Pengguna"</div>
|
||||
<p class="card-description">"Ringkasan aktivitas dan trust score pengguna."</p>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="dashboard-list-toolbar">
|
||||
<input
|
||||
class="input w-full"
|
||||
placeholder="Search users..."
|
||||
prop:value=search
|
||||
on:input=move |ev| search_cb.with_value(|cb| cb(event_target_value(&ev)))
|
||||
/>
|
||||
</div>
|
||||
|
||||
{move || {
|
||||
if loading && users.is_empty() {
|
||||
view! { <ListSkeleton /> }.into_any()
|
||||
} else if let Some(err) = error.clone() {
|
||||
view! {
|
||||
<div class="dashboard-list-empty">
|
||||
<div class="text-error text-xl">"⚠"</div>
|
||||
<p class="text-sm text-secondary">{err}</p>
|
||||
<button class="btn btn-outline btn-sm" on:click=move |_| retry_cb.with_value(|cb| cb())>
|
||||
"Retry"
|
||||
</button>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else if users.is_empty() {
|
||||
view! {
|
||||
<div class="dashboard-list-empty">
|
||||
<div class="text-2xl">"👤"</div>
|
||||
<p class="text-sm text-secondary">"No users found."</p>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="dashboard-summary-list">
|
||||
{users.clone().into_iter().map(|user| view! {
|
||||
<UserRow user=user />
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
}}
|
||||
|
||||
{move || {
|
||||
(has_more && !loading).then(|| view! {
|
||||
<div class="mt-4 text-center">
|
||||
<button class="btn btn-outline btn-sm" on:click=move |_| load_more_cb.with_value(|cb| cb())>
|
||||
"Load more users"
|
||||
</button>
|
||||
</div>
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn UserRow(user: DashboardUser) -> impl IntoView {
|
||||
let name = user.username.clone().unwrap_or_else(|| user.user_id.clone());
|
||||
let summary = user
|
||||
.profile_summary
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{} messages", format_number(user.total_messages)));
|
||||
let trust = user.trust_score.map(|score| format!("Trust: {:.2}", score));
|
||||
let last_seen = user.last_message_at.map(format_timestamp);
|
||||
|
||||
view! {
|
||||
<div class="dashboard-summary-row">
|
||||
<div class="dashboard-summary-avatar">
|
||||
{if let Some(url) = user.avatar_url.clone() {
|
||||
view! { <img src=url alt="" class="dashboard-summary-avatar-img" /> }.into_any()
|
||||
} else {
|
||||
view! { <span>"👤"</span> }.into_any()
|
||||
}}
|
||||
</div>
|
||||
<div class="dashboard-summary-main">
|
||||
<div class="dashboard-summary-title">{name}</div>
|
||||
<div class="dashboard-summary-text">{summary}</div>
|
||||
<div class="dashboard-summary-meta">
|
||||
<span>{format!("{} flagged", format_number(user.flagged_count))}</span>
|
||||
{trust.map(|t| view! { <span>{t}</span> })}
|
||||
{last_seen.map(|t| view! { <span>{format!("Last: {}", t)}</span> })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ListSkeleton() -> impl IntoView {
|
||||
view! {
|
||||
<div class="dashboard-summary-list">
|
||||
{(0..5).map(|_| view! {
|
||||
<div class="dashboard-summary-row">
|
||||
<div class="skeleton skeleton-circular" style="width:40px;height:40px"></div>
|
||||
<div class="dashboard-summary-main">
|
||||
<div class="skeleton" style="height:16px;width:160px"></div>
|
||||
<div class="skeleton mt-2" style="height:14px;width:240px"></div>
|
||||
</div>
|
||||
</div>
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
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(','); }
|
||||
out.push(ch);
|
||||
}
|
||||
out.chars().rev().collect()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
pub mod components;
|
||||
|
||||
use components::{ChannelSummaryList, StatsOverview, UserSummaryList};
|
||||
use leptos::prelude::*;
|
||||
use shared_types::dashboard::{DashboardChannel, DashboardStats, DashboardUser};
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum DashboardTab {
|
||||
Stats,
|
||||
Users,
|
||||
Channels,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DashboardPanel() -> impl IntoView {
|
||||
let active_tab = RwSignal::new(DashboardTab::Stats);
|
||||
|
||||
let stats = RwSignal::new(None::<DashboardStats>);
|
||||
let stats_loading = RwSignal::new(false);
|
||||
let stats_error = RwSignal::new(None::<String>);
|
||||
|
||||
let users = RwSignal::new(Vec::<DashboardUser>::new());
|
||||
let users_loading = RwSignal::new(false);
|
||||
let users_error = RwSignal::new(None::<String>);
|
||||
let users_search = RwSignal::new(String::new());
|
||||
let users_cursor = RwSignal::new(None::<String>);
|
||||
|
||||
let channels = RwSignal::new(Vec::<DashboardChannel>::new());
|
||||
let channels_loading = RwSignal::new(false);
|
||||
let channels_error = RwSignal::new(None::<String>);
|
||||
let channels_search = RwSignal::new(String::new());
|
||||
let channels_cursor = RwSignal::new(None::<String>);
|
||||
|
||||
let fetch_stats: Arc<dyn Fn() + Send + Sync + 'static> = Arc::new(move || {
|
||||
stats_loading.set(true);
|
||||
stats_error.set(None);
|
||||
spawn_local(async move {
|
||||
match crate::api::dashboard::get_dashboard_stats().await {
|
||||
Ok(data) => stats.set(Some(data)),
|
||||
Err(err) => stats_error.set(Some(format!("Failed to load stats: {}", err))),
|
||||
}
|
||||
stats_loading.set(false);
|
||||
});
|
||||
});
|
||||
|
||||
let fetch_users: Arc<dyn Fn(bool) + Send + Sync + 'static> = Arc::new(move |reset: bool| {
|
||||
if users_loading.get() {
|
||||
return;
|
||||
}
|
||||
users_loading.set(true);
|
||||
users_error.set(None);
|
||||
|
||||
let cursor = if reset { None } else { users_cursor.get() };
|
||||
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 {
|
||||
Ok(page) => {
|
||||
if reset {
|
||||
users.set(page.data);
|
||||
} else {
|
||||
let mut current = users.get();
|
||||
current.extend(page.data);
|
||||
users.set(current);
|
||||
}
|
||||
users_cursor.set(page.next_cursor);
|
||||
}
|
||||
Err(err) => users_error.set(Some(format!("Failed to load users: {}", err))),
|
||||
}
|
||||
users_loading.set(false);
|
||||
});
|
||||
});
|
||||
|
||||
let fetch_channels: Arc<dyn Fn(bool) + Send + Sync + 'static> = Arc::new(move |reset: bool| {
|
||||
if channels_loading.get() {
|
||||
return;
|
||||
}
|
||||
channels_loading.set(true);
|
||||
channels_error.set(None);
|
||||
|
||||
let cursor = if reset { None } else { channels_cursor.get() };
|
||||
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 {
|
||||
Ok(page) => {
|
||||
if reset {
|
||||
channels.set(page.data);
|
||||
} else {
|
||||
let mut current = channels.get();
|
||||
current.extend(page.data);
|
||||
channels.set(current);
|
||||
}
|
||||
channels_cursor.set(page.next_cursor);
|
||||
}
|
||||
Err(err) => channels_error.set(Some(format!("Failed to load channels: {}", err))),
|
||||
}
|
||||
channels_loading.set(false);
|
||||
});
|
||||
});
|
||||
|
||||
{
|
||||
let fetch_stats = fetch_stats.clone();
|
||||
let fetch_users = fetch_users.clone();
|
||||
let fetch_channels = fetch_channels.clone();
|
||||
create_effect(move |_| {
|
||||
fetch_stats();
|
||||
fetch_users(true);
|
||||
fetch_channels(true);
|
||||
});
|
||||
}
|
||||
|
||||
view! {
|
||||
<div class="dashboard-panel">
|
||||
<div class="dashboard-header">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">"Dashboard Guild"</h2>
|
||||
<p class="text-sm text-secondary mt-2">
|
||||
"Pantau statistik, profil pengguna, dan aktivitas kanal komunitas IMPHNEN."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab-list mb-6">
|
||||
<DashboardTabButton tab=DashboardTab::Stats active_tab=active_tab label="Statistik" icon="📊" />
|
||||
<DashboardTabButton tab=DashboardTab::Users active_tab=active_tab label="Pengguna" icon="👥" />
|
||||
<DashboardTabButton tab=DashboardTab::Channels active_tab=active_tab label="Kanal" icon="#" />
|
||||
</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({
|
||||
let fetch_stats = fetch_stats.clone();
|
||||
move || fetch_stats()
|
||||
})
|
||||
/>
|
||||
</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({
|
||||
let fetch_users = fetch_users.clone();
|
||||
move |value| {
|
||||
users_search.set(value);
|
||||
users_cursor.set(None);
|
||||
fetch_users(true);
|
||||
}
|
||||
})
|
||||
on_load_more=Box::new({
|
||||
let fetch_users = fetch_users.clone();
|
||||
move || fetch_users(false)
|
||||
})
|
||||
on_retry=Box::new({
|
||||
let fetch_users = fetch_users.clone();
|
||||
move || fetch_users(true)
|
||||
})
|
||||
/>
|
||||
</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({
|
||||
let fetch_channels = fetch_channels.clone();
|
||||
move |value| {
|
||||
channels_search.set(value);
|
||||
channels_cursor.set(None);
|
||||
fetch_channels(true);
|
||||
}
|
||||
})
|
||||
on_load_more=Box::new({
|
||||
let fetch_channels = fetch_channels.clone();
|
||||
move || fetch_channels(false)
|
||||
})
|
||||
on_retry=Box::new({
|
||||
let fetch_channels = fetch_channels.clone();
|
||||
move || fetch_channels(true)
|
||||
})
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn DashboardTabButton(
|
||||
tab: DashboardTab,
|
||||
active_tab: RwSignal<DashboardTab>,
|
||||
label: &'static str,
|
||||
icon: &'static str,
|
||||
) -> impl IntoView {
|
||||
let tab_for_class = tab.clone();
|
||||
let tab_for_aria = tab.clone();
|
||||
let tab_for_click = tab;
|
||||
|
||||
view! {
|
||||
<button
|
||||
class="tab-trigger"
|
||||
class:active=move || active_tab.get() == tab_for_class
|
||||
aria-selected=move || if active_tab.get() == tab_for_aria { "true" } else { "false" }
|
||||
on:click=move |_| active_tab.set(tab_for_click.clone())
|
||||
>
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod ring_buffer;
|
||||
pub mod pcm_decoder;
|
||||
@@ -0,0 +1,69 @@
|
||||
/// PCM Frame decoded from binary WebSocket data
|
||||
/// Format: [u32 userId (4 bytes)][i16 samples (N bytes)]
|
||||
pub struct PcmFrame {
|
||||
pub user_id: u32,
|
||||
pub samples: Vec<f32>, // Normalized to [-1.0, 1.0]
|
||||
}
|
||||
|
||||
/// Decode a binary WebSocket message into PCM frames
|
||||
/// Returns None if data is too short or malformed
|
||||
pub fn decode_pcm_frame(data: &[u8]) -> Option<PcmFrame> {
|
||||
if data.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let user_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
let sample_bytes = &data[4..];
|
||||
let sample_count = sample_bytes.len() / 2;
|
||||
|
||||
if sample_count == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let samples = decode_i16_samples(sample_bytes);
|
||||
Some(PcmFrame { user_id, samples })
|
||||
}
|
||||
|
||||
/// Decode raw i16 PCM bytes to normalized f32 samples [-1.0, 1.0]
|
||||
pub fn decode_i16_samples(data: &[u8]) -> Vec<f32> {
|
||||
let count = data.len() / 2;
|
||||
let mut out = Vec::with_capacity(count);
|
||||
|
||||
for i in 0..count {
|
||||
let offset = i * 2;
|
||||
if offset + 1 < data.len() {
|
||||
let sample = i16::from_le_bytes([data[offset], data[offset + 1]]);
|
||||
out.push((sample as f32) / 32768.0);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Encode f32 samples [-1.0, 1.0] to base64 for WebSocket transmission
|
||||
/// Uses JavaScript btoa for encoding
|
||||
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 int_sample = (clamped * 32767.0) as i16;
|
||||
bytes.extend_from_slice(&int_sample.to_le_bytes());
|
||||
}
|
||||
encode_bytes_base64(&bytes)
|
||||
}
|
||||
|
||||
/// Encode raw bytes to base64 using JavaScript's btoa
|
||||
fn encode_bytes_base64(data: &[u8]) -> String {
|
||||
// Build binary string for btoa
|
||||
let binary: String = data.iter().map(|&b| b as char).collect();
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
@@ -0,0 +1,124 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// AudioRingBuffer — Fixed-size circular buffer for real-time PCM streaming
|
||||
/// Provides thread-safe write/read with automatic overwrite protection
|
||||
pub struct AudioRingBuffer {
|
||||
buffer: Vec<f32>,
|
||||
capacity: usize,
|
||||
write_pos: usize,
|
||||
read_pos: usize,
|
||||
available: usize,
|
||||
}
|
||||
|
||||
impl AudioRingBuffer {
|
||||
/// Create a new ring buffer with given capacity (in samples)
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
buffer: vec![0.0; capacity],
|
||||
capacity,
|
||||
write_pos: 0,
|
||||
read_pos: 0,
|
||||
available: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write samples to the ring buffer. Overwrites oldest data if full.
|
||||
pub fn write(&mut self, samples: &[f32]) {
|
||||
let mut written = 0;
|
||||
while written < samples.len() {
|
||||
let chunk = (samples.len() - written).min(self.capacity - self.write_pos);
|
||||
let src = &samples[written..written + chunk];
|
||||
let dest = &mut self.buffer[self.write_pos..self.write_pos + chunk];
|
||||
dest.copy_from_slice(src);
|
||||
written += chunk;
|
||||
self.write_pos = (self.write_pos + chunk) % self.capacity;
|
||||
self.available = (self.available + chunk).min(self.capacity);
|
||||
// If we overwrote unread data, advance read_pos
|
||||
if self.available == self.capacity {
|
||||
self.read_pos = self.write_pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read up to `max_samples` from the buffer. Returns the samples read.
|
||||
pub fn read(&mut self, max_samples: usize) -> Vec<f32> {
|
||||
let to_read = max_samples.min(self.available);
|
||||
let mut out = Vec::with_capacity(to_read);
|
||||
let mut remaining = to_read;
|
||||
|
||||
while remaining > 0 {
|
||||
let chunk = remaining.min(self.capacity - self.read_pos);
|
||||
out.extend_from_slice(&self.buffer[self.read_pos..self.read_pos + chunk]);
|
||||
remaining -= chunk;
|
||||
self.read_pos = (self.read_pos + chunk) % self.capacity;
|
||||
}
|
||||
|
||||
self.available -= to_read;
|
||||
out
|
||||
}
|
||||
|
||||
/// Number of samples available to read
|
||||
pub fn available_samples(&self) -> usize {
|
||||
self.available
|
||||
}
|
||||
|
||||
/// Clear all buffered data
|
||||
pub fn clear(&mut self) {
|
||||
self.write_pos = 0;
|
||||
self.read_pos = 0;
|
||||
self.available = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper around AudioRingBuffer
|
||||
pub struct SharedRingBuffer {
|
||||
inner: Arc<Mutex<AudioRingBuffer>>,
|
||||
}
|
||||
|
||||
impl SharedRingBuffer {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(AudioRingBuffer::new(capacity))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(&self, samples: &[f32]) {
|
||||
if let Ok(mut guard) = self.inner.lock() {
|
||||
guard.write(samples);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self, max_samples: usize) -> Vec<f32> {
|
||||
if let Ok(mut guard) = self.inner.lock() {
|
||||
guard.read(max_samples)
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn available_samples(&self) -> usize {
|
||||
if let Ok(guard) = self.inner.lock() {
|
||||
guard.available_samples()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
if let Ok(mut guard) = self.inner.lock() {
|
||||
guard.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clone_inner(&self) -> Arc<Mutex<AudioRingBuffer>> {
|
||||
self.inner.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for SharedRingBuffer {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: self.inner.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::voice::ActiveSpeaker;
|
||||
|
||||
/// ActiveSpeakers component for Leptos
|
||||
/// Displays a real-time list of speaking users with avatar and status indicator
|
||||
#[component]
|
||||
pub fn ActiveSpeakers(
|
||||
#[prop(optional)] speakers: RwSignal<Vec<ActiveSpeaker>>,
|
||||
#[prop(optional)] class: &'static str,
|
||||
) -> impl IntoView {
|
||||
let empty_state = move || speakers.get().is_empty();
|
||||
|
||||
view! {
|
||||
<div class=class>
|
||||
<Show
|
||||
when=empty_state
|
||||
fallback=move || {
|
||||
view! {
|
||||
<div class="space-y-2">
|
||||
<For
|
||||
each=move || speakers.get()
|
||||
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">
|
||||
{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"
|
||||
/>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
<div class="min-w-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 || {
|
||||
if speaker.speaking {
|
||||
"inline-block h-2 w-2 rounded-full bg-emerald-500"
|
||||
} else {
|
||||
"inline-block h-2 w-2 rounded-full bg-muted-foreground/40"
|
||||
}
|
||||
}></span>
|
||||
<span class=move || {
|
||||
if speaker.speaking {
|
||||
"text-xs font-medium text-emerald-600 dark:text-emerald-400"
|
||||
} else {
|
||||
"text-xs font-medium text-muted-foreground"
|
||||
}
|
||||
}>
|
||||
{move || if speaker.speaking { "Speaking" } else { "Silent" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</For>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
"No active speakers"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use leptos::prelude::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// AudioVisualizer — Real-time 32-bar frequency spectrum display
|
||||
/// Simplified implementation using CSS bars updated via signals
|
||||
#[component]
|
||||
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]);
|
||||
|
||||
// Periodically update bars from PCM data
|
||||
create_effect(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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="audio-visualizer">
|
||||
<div class="audio-visualizer-bars">
|
||||
{(0..32).map(|i| {
|
||||
view! {
|
||||
<div
|
||||
class="audio-bar"
|
||||
style=move || {
|
||||
let height = bars.get()[i] * 100.0;
|
||||
format!("height: {}%", height)
|
||||
}
|
||||
></div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute 32-band frequency spectrum from PCM samples
|
||||
fn compute_frequency_bands(pcm_samples: &[f32]) -> Vec<f32> {
|
||||
let mut bands = vec![0.0; 32];
|
||||
|
||||
if pcm_samples.is_empty() {
|
||||
return bands;
|
||||
}
|
||||
|
||||
let samples_per_band = (pcm_samples.len() / 32).max(1);
|
||||
|
||||
for (band_idx, band) in bands.iter_mut().enumerate() {
|
||||
let start = band_idx * samples_per_band;
|
||||
let end = ((band_idx + 1) * samples_per_band).min(pcm_samples.len());
|
||||
|
||||
if start < pcm_samples.len() {
|
||||
let slice = &pcm_samples[start..end];
|
||||
let rms = (slice.iter().map(|s| s * s).sum::<f32>() / slice.len() as f32).sqrt();
|
||||
*band = rms.min(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
bands
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use leptos::prelude::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// MicLevelMeter — Horizontal level indicator for microphone input
|
||||
/// Displays 0-100% amplitude as a filling bar with smooth decay
|
||||
#[component]
|
||||
pub fn MicLevelMeter(
|
||||
#[prop(default = true)] active: bool,
|
||||
#[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);
|
||||
|
||||
// Update level periodically
|
||||
create_effect(move |_| {
|
||||
if !active {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(ref pcm_arc) = pcm_data {
|
||||
if let Ok(pcm_vec) = pcm_arc.lock() {
|
||||
let current_level = compute_rms(&pcm_vec);
|
||||
level.update(|l| {
|
||||
*l = *l * 0.8 + current_level * 0.2; // Smooth decay
|
||||
});
|
||||
peak.update(|p| {
|
||||
*p = (*p * 0.95).max(current_level); // Peak hold with decay
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let level_percent = move || (level.get() * 100.0).min(100.0);
|
||||
let peak_percent = move || (peak.get() * 100.0).min(100.0);
|
||||
|
||||
// Determine color based on level
|
||||
let level_color = move || {
|
||||
let l = level.get();
|
||||
if l < 0.5 {
|
||||
"bg-green-500"
|
||||
} else if l < 0.75 {
|
||||
"bg-yellow-500"
|
||||
} else {
|
||||
"bg-red-500"
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="mic-level-meter">
|
||||
{label.map(|l| view! {
|
||||
<label class="text-xs font-medium text-foreground mb-1.5">{l}</label>
|
||||
})}
|
||||
<div class="flex items-center gap-2">
|
||||
{/* Main level bar */}
|
||||
<div class="relative flex-1 h-2 rounded-full bg-surface border border-border/50 overflow-hidden">
|
||||
<div
|
||||
class=move || format!("h-full {} transition-all", level_color())
|
||||
style=move || format!("width: {}%", level_percent())
|
||||
></div>
|
||||
{/* Peak indicator */}
|
||||
<div
|
||||
class="absolute h-full w-0.5 bg-destructive/70"
|
||||
style=move || format!("left: {}%", peak_percent())
|
||||
></div>
|
||||
</div>
|
||||
{/* Percentage display */}
|
||||
<span class="text-xs font-mono text-muted-foreground w-8 text-right">
|
||||
{move || format!("{}%", (level_percent() as u8))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute RMS (Root Mean Square) amplitude from PCM samples
|
||||
/// Returns normalized value 0.0-1.0
|
||||
fn compute_rms(samples: &[f32]) -> f32 {
|
||||
if samples.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mean_square = samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32;
|
||||
mean_square.sqrt()
|
||||
}
|
||||
@@ -0,0 +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 recordings_sub_panel;
|
||||
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 recordings_sub_panel::RecordingsSubPanel;
|
||||
pub use waveform_player::WaveformPlayer;
|
||||
@@ -0,0 +1,56 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// MusicSubPanel — Music playlist controls and URL input
|
||||
#[component]
|
||||
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 handle_queue_click = move |_| {
|
||||
let url = url_input.get().trim().to_string();
|
||||
if !url.is_empty() {
|
||||
if let Some(ref cb) = on_queue {
|
||||
set_is_loading.set(true);
|
||||
cb(url.clone());
|
||||
set_url_input.set(String::new());
|
||||
set_is_loading.set(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M9 8h6v8h-6z"></path>
|
||||
</svg>
|
||||
"Music"
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-3">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-medium text-foreground">"YouTube URL or Search"</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input w-full text-sm"
|
||||
placeholder="youtube.com/watch?v=... or song name"
|
||||
prop:value=url_input
|
||||
on:input=move |ev| set_url_input.set(event_target_value(&ev))
|
||||
disabled=move || is_loading.get()
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class=move || format!("btn btn-primary w-full {}", if is_loading.get() { "opacity-50" } else { "" })
|
||||
on:click=handle_queue_click
|
||||
disabled=move || url_input.get().is_empty() || is_loading.get()
|
||||
>
|
||||
{move || if is_loading.get() { "Queuing..." } else { "Queue Music" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::media::MediaState;
|
||||
|
||||
/// NowPlaying — Displays current media item and queue info
|
||||
#[component]
|
||||
pub fn NowPlaying(
|
||||
#[prop(optional)] state: Option<MediaState>,
|
||||
#[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);
|
||||
|
||||
// Wrap callbacks in StoredValue for shareable non-Clone ownership in Leptos context
|
||||
let skip_cb = StoredValue::new(on_skip);
|
||||
let stop_cb = StoredValue::new(on_stop);
|
||||
let has_skip = skip_cb.with_value(|v| v.is_some());
|
||||
let has_stop = stop_cb.with_value(|v| v.is_some());
|
||||
|
||||
view! {
|
||||
<div class="now-playing card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Now Playing"</div>
|
||||
</div>
|
||||
<div class="card-content space-y-3">
|
||||
{move || {
|
||||
media_state.get().map(|ms| {
|
||||
let current = ms.current.as_ref().cloned();
|
||||
let queue_len = ms.queue.len();
|
||||
|
||||
view! {
|
||||
<>
|
||||
{current.map(|item| {
|
||||
let title = item.title.clone().unwrap_or_else(|| "Unknown".to_string());
|
||||
let duration_ms = item.duration_ms.unwrap_or(0);
|
||||
let duration_sec = duration_ms / 1000;
|
||||
view! {
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-foreground truncate">
|
||||
{title}
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{format!("{}s", duration_sec)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{has_skip.then(|| {
|
||||
view! {
|
||||
<button
|
||||
class="btn btn-sm btn-outline flex-1"
|
||||
on:click=move |_| { skip_cb.with_value(|cb| { if let Some(cb) = cb { cb(); } }); }
|
||||
>
|
||||
"⏭ Skip"
|
||||
</button>
|
||||
}
|
||||
})}
|
||||
{has_stop.then(|| {
|
||||
view! {
|
||||
<button
|
||||
class="btn btn-sm btn-destructive flex-1"
|
||||
on:click=move |_| { stop_cb.with_value(|cb| { if let Some(cb) = cb { cb(); } }); }
|
||||
>
|
||||
"⏹ Stop"
|
||||
</button>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{(queue_len > 0).then(|| {
|
||||
view! {
|
||||
<div class="border-t border-border/50 pt-3">
|
||||
<div class="text-xs font-medium text-muted-foreground">
|
||||
{format!("Queue: {} item{}", queue_len, if queue_len == 1 { "" } else { "s" })}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{(queue_len == 0).then(|| {
|
||||
view! {
|
||||
<div class="text-xs text-muted-foreground text-center py-2">
|
||||
"Queue is empty"
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</>
|
||||
}
|
||||
})
|
||||
}}
|
||||
|
||||
{move || {
|
||||
media_state.get().is_none().then(|| {
|
||||
view! {
|
||||
<div class="text-xs text-muted-foreground text-center py-4">
|
||||
"No media connected"
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
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);
|
||||
|
||||
// Load recordings
|
||||
let load = move |reset: bool| {
|
||||
if loading.get() { return; }
|
||||
loading.set(true);
|
||||
|
||||
let cursor_val = if reset { None } else { next_cursor.get() };
|
||||
wasm_bindgen_futures::spawn_local({
|
||||
async move {
|
||||
match get_recordings(Some(20), cursor_val.as_deref()).await {
|
||||
Ok(resp) => {
|
||||
if reset {
|
||||
recordings.set(resp.items);
|
||||
} else {
|
||||
let mut current = recordings.get();
|
||||
current.extend(resp.items);
|
||||
recordings.set(current);
|
||||
}
|
||||
has_more.set(resp.has_more);
|
||||
next_cursor.set(resp.next_cursor);
|
||||
}
|
||||
Err(_) => {
|
||||
if reset {
|
||||
recordings.set(Vec::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Load on mount
|
||||
create_effect(move |_| {
|
||||
load(true);
|
||||
});
|
||||
|
||||
// Delete recording handler
|
||||
let do_delete = move |id: String| {
|
||||
wasm_bindgen_futures::spawn_local({
|
||||
let id = id.clone();
|
||||
async move {
|
||||
let _ = delete_recording(&id).await;
|
||||
recordings.update(|r| r.retain(|rec| rec.id != id));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<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">
|
||||
<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>
|
||||
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||
</svg>
|
||||
"Recordings"
|
||||
</div>
|
||||
<p class="card-description">"Voice channel recordings from all sessions."</p>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
{move || {
|
||||
let recs = recordings.get();
|
||||
if recs.is_empty() && !loading.get() {
|
||||
view! {
|
||||
<div class="flex flex-col items-center justify-center py-8 gap-2">
|
||||
<p class="text-sm text-muted-foreground">"No recordings yet."</p>
|
||||
<p class="text-xs text-muted-foreground">"Join a voice channel to start recording."</p>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="space-y-2">
|
||||
{recs.iter().map(|rec| {
|
||||
let id = rec.id.clone();
|
||||
let username = rec.username.clone();
|
||||
let channel_name = rec.channel_name.clone().unwrap_or_default();
|
||||
let created_at = format_timestamp(rec.created_at);
|
||||
let has_url = rec.download_url.is_some();
|
||||
let url = rec.download_url.clone().unwrap_or_default();
|
||||
|
||||
view! {
|
||||
<div class="recording-item flex items-center gap-3 p-3 rounded-lg border border-border/50 hover:bg-accent/5 transition-colors">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-foreground truncate">
|
||||
{username}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{channel_name}</span>
|
||||
<span>"·"</span>
|
||||
<span>{format_size(rec.size_bytes)}</span>
|
||||
<span>"·"</span>
|
||||
<span>{created_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
{has_url.then(|| {
|
||||
view! {
|
||||
<a
|
||||
href=url
|
||||
target="_blank"
|
||||
class="btn btn-sm btn-outline"
|
||||
>
|
||||
"Download"
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
<button
|
||||
class="btn btn-sm btn-ghost text-destructive hover:text-destructive"
|
||||
on:click=move |_| do_delete(id.clone())
|
||||
>
|
||||
"🗑"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
}}
|
||||
|
||||
{move || {
|
||||
(has_more.get() && !loading.get()).then(|| {
|
||||
view! {
|
||||
<div class="mt-3 text-center">
|
||||
<button
|
||||
class="btn btn-sm btn-outline"
|
||||
on:click=move |_| load(false)
|
||||
>
|
||||
"Load more"
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Format file size bytes to human readable
|
||||
fn format_size(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{} B", bytes)
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// ScreenSubPanel — Screenshare controls
|
||||
#[component]
|
||||
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 has_start = on_start_stream.is_some();
|
||||
let has_stop = on_stop_stream.is_some();
|
||||
|
||||
view! {
|
||||
<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">
|
||||
<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>
|
||||
</svg>
|
||||
"Screenshare"
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-3">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
"Stream your screen to the voice channel for everyone to see."
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{has_start.then(|| {
|
||||
view! {
|
||||
<button
|
||||
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() {
|
||||
set_is_streaming.set(true);
|
||||
if let Some(ref cb) = on_start_stream {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
"🔴 Start Stream"
|
||||
</button>
|
||||
}
|
||||
})}
|
||||
|
||||
{has_stop.then(|| {
|
||||
view! {
|
||||
<button
|
||||
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() {
|
||||
set_is_streaming.set(false);
|
||||
if let Some(ref cb) = on_stop_stream {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
"⏹ Stop Stream"
|
||||
</button>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
{move || {
|
||||
is_streaming.get().then(|| {
|
||||
view! {
|
||||
<div class="rounded-md bg-success/10 px-2 py-1.5 text-xs text-success">
|
||||
"🔴 Live streaming..."
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use crate::features::live::hooks::use_voice_control::{use_voice_control, VoiceControlState};
|
||||
|
||||
/// VoiceConnectionCard component for Leptos
|
||||
/// Renders guild and voice channel selectors with connect/disconnect controls
|
||||
#[component]
|
||||
pub fn VoiceConnectionCard(
|
||||
#[prop(optional)] voice_state: Option<VoiceControlState>,
|
||||
#[prop(optional)] class: &'static str,
|
||||
) -> impl IntoView {
|
||||
let default_state = use_voice_control();
|
||||
let state = voice_state.unwrap_or(default_state);
|
||||
|
||||
// Reactive signal for selected guild
|
||||
let (selected_guild, set_selected_guild) = create_signal::<String>(String::new());
|
||||
// Reactive signal for selected channel
|
||||
let (selected_channel, set_selected_channel) = create_signal::<String>(String::new());
|
||||
|
||||
// When guild is selected, load voice channels
|
||||
create_effect(move |_| {
|
||||
let guild_id = selected_guild.get();
|
||||
if !guild_id.is_empty() {
|
||||
(state.load_voice_channels)(guild_id);
|
||||
}
|
||||
});
|
||||
|
||||
// Load guilds on mount
|
||||
create_effect(move |_| {
|
||||
(state.load_guilds)();
|
||||
});
|
||||
|
||||
let on_guild_change = move |ev: leptos::ev::Event| {
|
||||
if let Some(target) = ev.target() {
|
||||
if let Ok(select_el) = target.dyn_into::<web_sys::HtmlSelectElement>() {
|
||||
set_selected_guild.set(select_el.value());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let on_channel_change = move |ev: leptos::ev::Event| {
|
||||
if let Some(target) = ev.target() {
|
||||
if let Ok(select_el) = target.dyn_into::<web_sys::HtmlSelectElement>() {
|
||||
set_selected_channel.set(select_el.value());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let on_join_click = move |_| {
|
||||
let guild_id = selected_guild.get();
|
||||
let channel_id = selected_channel.get();
|
||||
if !guild_id.is_empty() && !channel_id.is_empty() {
|
||||
(state.join_voice)(guild_id, channel_id);
|
||||
}
|
||||
};
|
||||
|
||||
let on_disconnect_click = move |_| {
|
||||
(state.leave_voice)();
|
||||
};
|
||||
|
||||
// Read signals for reactive rendering
|
||||
let guilds = state.guilds;
|
||||
let voice_channels = state.voice_channels;
|
||||
let loading = state.loading;
|
||||
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 can_join = move || {
|
||||
!selected_guild.get().is_empty() && !selected_channel.get().is_empty() && !loading.get()
|
||||
};
|
||||
|
||||
let can_disconnect = move || {
|
||||
is_connected() && !loading.get()
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class=format!("rounded-xl border border-border bg-card shadow-sm {}", class)>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg
|
||||
class="h-5 w-5 text-primary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold tracking-tight">"Voice Bridge"</h3>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
"Join a Discord voice channel, listen, and transmit audio."
|
||||
</p>
|
||||
|
||||
{/* Guild and Channel Selectors */}
|
||||
<div class="grid gap-4 md:grid-cols-2 mb-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-foreground">"Guild"</label>
|
||||
<select
|
||||
prop:value=selected_guild
|
||||
on:change=on_guild_change
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">"Select guild"</option>
|
||||
<For each=move || guilds.get() key=|g| g.id.clone() let:guild>
|
||||
<option value=guild.id.clone()>
|
||||
{guild.name.clone()}
|
||||
</option>
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-foreground">"Voice Channel"</label>
|
||||
<select
|
||||
prop:value=selected_channel
|
||||
on:change=on_channel_change
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">"Select voice channel"</option>
|
||||
<For each=move || voice_channels.get() key=|c| c.id.clone() let:channel>
|
||||
<option value=channel.id.clone()>
|
||||
{channel.name.clone()}
|
||||
</option>
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{move || {
|
||||
error.get().map(|err| {
|
||||
view! {
|
||||
<div class="rounded-md bg-destructive/15 px-3 py-2 text-sm text-destructive mb-4">
|
||||
{err}
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}}
|
||||
|
||||
{/* Status Display */}
|
||||
{move || {
|
||||
voice_status.get().map(|status| {
|
||||
let connected = status.connected;
|
||||
let active_channel = status.active_channel_name.clone();
|
||||
view! {
|
||||
<div class="flex items-center gap-2 text-sm mb-4">
|
||||
<div class=move || {
|
||||
if connected {
|
||||
"h-2 w-2 rounded-full bg-emerald-500"
|
||||
} else {
|
||||
"h-2 w-2 rounded-full bg-muted-foreground/40"
|
||||
}
|
||||
}></div>
|
||||
<span class=move || {
|
||||
if connected {
|
||||
"text-emerald-600 dark:text-emerald-400 font-medium"
|
||||
} else {
|
||||
"text-muted-foreground"
|
||||
}
|
||||
}>
|
||||
{if connected { "Connected" } else { "Disconnected" }}
|
||||
</span>
|
||||
{active_channel.map(|name| {
|
||||
view! {
|
||||
<span class="text-muted-foreground">
|
||||
{format!(" - {}", name)}
|
||||
</span>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}}
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class=move || {
|
||||
if can_join() {
|
||||
"btn btn-primary"
|
||||
} else {
|
||||
"btn btn-primary opacity-50 cursor-not-allowed"
|
||||
}
|
||||
}
|
||||
disabled=move || !can_join()
|
||||
on:click=on_join_click
|
||||
>
|
||||
{move || if is_connected() { "Reconnect" } else { "Join Voice" }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class=move || {
|
||||
if can_disconnect() {
|
||||
"btn btn-destructive"
|
||||
} else {
|
||||
"btn btn-destructive opacity-50 cursor-not-allowed"
|
||||
}
|
||||
}
|
||||
disabled=move || !can_disconnect()
|
||||
on:click=on_disconnect_click
|
||||
>
|
||||
"Disconnect"
|
||||
</button>
|
||||
|
||||
{move || {
|
||||
if loading.get() {
|
||||
view! {
|
||||
<span class="inline-flex items-center px-3 py-2 text-sm text-muted-foreground">
|
||||
"Loading..."
|
||||
</span>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! { <></> }.into_any()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// WaveformPlayer — Audio player with waveform progress bar
|
||||
#[component]
|
||||
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);
|
||||
|
||||
// Clone audio_url for the audio element
|
||||
let audio_src = audio_url.clone();
|
||||
let audio_src_for_id = audio_src.clone();
|
||||
|
||||
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));
|
||||
if let Some(audio_el) = audio_opt {
|
||||
if let Ok(audio) = audio_el.dyn_into::<web_sys::HtmlAudioElement>() {
|
||||
if is_playing.get() {
|
||||
let _ = audio.pause();
|
||||
is_playing.set(false);
|
||||
} else {
|
||||
if audio.ended() {
|
||||
audio.set_current_time(0.0);
|
||||
}
|
||||
if let Ok(_) = audio.play() {
|
||||
is_playing.set(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let _ = audio_url; // Mark as used for the audio_id
|
||||
|
||||
view! {
|
||||
<div class="waveform-player border border-border/50 rounded-lg p-3 bg-surface/30">
|
||||
<audio
|
||||
id=audio_id.clone()
|
||||
preload="auto"
|
||||
src=audio_src
|
||||
class="hidden"
|
||||
on:timeupdate=move |ev| {
|
||||
if let Some(target) = ev.target() {
|
||||
if let Ok(audio) = target.dyn_into::<web_sys::HtmlAudioElement>() {
|
||||
let ct = audio.current_time();
|
||||
let dur = audio.duration();
|
||||
current_time.set(ct);
|
||||
if dur.is_finite() && dur > 0.0 {
|
||||
duration.set(dur);
|
||||
}
|
||||
if audio.ended() {
|
||||
is_playing.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
></audio>
|
||||
|
||||
<div class="h-2 rounded-full bg-surface border border-border/50 overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-200"
|
||||
style=move || format!("width: {}%", progress_pct(current_time.get(), duration.get()))
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<button
|
||||
class=move || format!("btn btn-sm {}", if is_playing.get() { "btn-secondary" } else { "btn-primary" })
|
||||
on:click=toggle_play
|
||||
>
|
||||
{move || if is_playing.get() { "⏸" } else { "▶" }}
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground font-mono">
|
||||
<span>{move || format_time(current_time.get())}</span>
|
||||
<span class="max-w-32 truncate">{title.clone()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn progress_pct(current: f64, dur: f64) -> f64 {
|
||||
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(); }
|
||||
let total = secs as u32;
|
||||
format!("{:02}:{:02}", total / 60, total % 60)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod use_voice_control;
|
||||
pub mod use_media_control;
|
||||
pub mod use_audio_playback;
|
||||
pub mod use_audio_transmit;
|
||||
@@ -0,0 +1,107 @@
|
||||
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;
|
||||
|
||||
/// AudioPlaybackState — Manages PCM audio playback from WebSocket binary frames
|
||||
pub struct AudioPlaybackState {
|
||||
/// Ring buffer for incoming PCM data
|
||||
pub buffer: SharedRingBuffer,
|
||||
/// Whether playback is active
|
||||
pub active: RwSignal<bool>,
|
||||
/// Volume level (0.0-1.0)
|
||||
pub volume: RwSignal<f64>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
AudioPlaybackState {
|
||||
buffer,
|
||||
active,
|
||||
volume,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process incoming binary data from WebSocket (PCM audio frame)
|
||||
/// Format: [u32 userId (4 bytes)][i16 samples (N bytes)]
|
||||
pub fn process_pcm_data(state: &AudioPlaybackState, data: Vec<u8>) {
|
||||
if let Some(frame) = decode_pcm_frame(&data) {
|
||||
state.buffer.write(&frame.samples);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start consuming the ring buffer and playing through AudioContext
|
||||
pub fn start_playback(state: &AudioPlaybackState) {
|
||||
if state.active.get() {
|
||||
return;
|
||||
}
|
||||
state.active.set(true);
|
||||
|
||||
let buffer = state.buffer.clone();
|
||||
let active = state.active;
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let ctx = match web_sys::AudioContext::new() {
|
||||
Ok(ctx) => ctx,
|
||||
Err(_) => {
|
||||
active.set(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ctx_ref = &ctx;
|
||||
let _ = ctx_ref.resume();
|
||||
|
||||
while active.get() {
|
||||
let available = buffer.available_samples();
|
||||
if available >= 4410 {
|
||||
// ~100ms worth at 44.1kHz
|
||||
let samples = buffer.read(4410);
|
||||
if !samples.is_empty() {
|
||||
play_samples(&ctx, &samples);
|
||||
}
|
||||
}
|
||||
let _ = gloo_timers::future::TimeoutFuture::new(50).await;
|
||||
}
|
||||
|
||||
let _ = ctx.close();
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop playback and clear buffer
|
||||
pub fn stop_playback(state: &AudioPlaybackState) {
|
||||
state.active.set(false);
|
||||
state.buffer.clear();
|
||||
}
|
||||
|
||||
/// Play a chunk of PCM samples through AudioContext using AudioBufferSourceNode
|
||||
fn play_samples(ctx: &web_sys::AudioContext, samples: &[f32]) {
|
||||
let frame_count = samples.len() as u32;
|
||||
let Ok(audio_buffer) = ctx.create_buffer(1, frame_count, ctx.sample_rate()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Write samples into the buffer channel
|
||||
let Ok(channel_data) = audio_buffer.get_channel_data(0) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let len = samples.len().min(channel_data.len() as usize);
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy samples directly to audio buffer channel
|
||||
let _ = audio_buffer.copy_to_channel(&samples[..len], 0);
|
||||
|
||||
// Create source and play
|
||||
if let Ok(source) = ctx.create_buffer_source() {
|
||||
source.set_buffer(Some(&audio_buffer));
|
||||
source.set_loop(false);
|
||||
let _ = source.start();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::{MediaStream, MediaStreamConstraints, MediaStreamTrack};
|
||||
|
||||
/// AudioTransmitState — Manages microphone capture state
|
||||
pub struct AudioTransmitState {
|
||||
pub active: RwSignal<bool>,
|
||||
pub stream: StoredValue<Option<MediaStream>>,
|
||||
}
|
||||
|
||||
/// Create microphone transmit state
|
||||
pub fn use_audio_transmit() -> AudioTransmitState {
|
||||
let active = create_rw_signal::<bool>(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() {
|
||||
return;
|
||||
}
|
||||
state.active.set(true);
|
||||
|
||||
let constraints = MediaStreamConstraints::new();
|
||||
let _ = js_sys::Reflect::set(
|
||||
&constraints,
|
||||
&JsValue::from_str("audio"),
|
||||
&JsValue::from_bool(true),
|
||||
);
|
||||
|
||||
let window = match web_sys::window() {
|
||||
Some(w) => w,
|
||||
None => return,
|
||||
};
|
||||
let media_devices = match window.navigator().media_devices() {
|
||||
Ok(md) => md,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let promise = match media_devices.get_user_media_with_constraints(&constraints) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// Clone signals before spawning async task to avoid reference escaping
|
||||
let active_signal = state.active;
|
||||
let stream_signal = state.stream;
|
||||
|
||||
spawn_local(async move {
|
||||
match wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||
Ok(val) => {
|
||||
if let Ok(s) = val.dyn_into::<MediaStream>() {
|
||||
stream_signal.set_value(Some(s));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
active_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop microphone transmission
|
||||
pub fn stop_transmit(state: &AudioTransmitState) {
|
||||
state.active.set(false);
|
||||
state.stream.update_value(|s| {
|
||||
if let Some(stream) = s.take() {
|
||||
let tracks = stream.get_tracks();
|
||||
for i in 0..tracks.length() {
|
||||
let track_val = tracks.get(i);
|
||||
if let Ok(track) = track_val.dyn_into::<MediaStreamTrack>() {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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;
|
||||
|
||||
/// Callback type for enqueue
|
||||
pub type EnqueueCallback = Arc<dyn Fn(String, String) + Send + Sync>;
|
||||
/// Callback type for skip_track
|
||||
pub type SkipTrackCallback = Arc<dyn Fn() + Send + Sync>;
|
||||
/// Callback type for stop_playback
|
||||
pub type StopPlaybackCallback = Arc<dyn Fn() + Send + Sync>;
|
||||
/// Callback type for set_volume
|
||||
pub type SetVolumeCallback = Arc<dyn Fn(f64) + Send + Sync>;
|
||||
/// Callback type for refresh
|
||||
pub type RefreshCallback = Arc<dyn Fn() + Send + Sync>;
|
||||
|
||||
/// State returned by use_media_control hook
|
||||
#[derive(Clone)]
|
||||
pub struct MediaControlState {
|
||||
/// Current media playback state
|
||||
pub media_state: RwSignal<Option<MediaState>>,
|
||||
/// Whether we're currently loading data
|
||||
pub loading: RwSignal<bool>,
|
||||
/// Last error message if any
|
||||
pub error: RwSignal<Option<String>>,
|
||||
/// Enqueue media (source URL, mode: "music" or "screen")
|
||||
pub enqueue: EnqueueCallback,
|
||||
/// Skip to next track
|
||||
pub skip_track: SkipTrackCallback,
|
||||
/// Stop all playback
|
||||
pub stop_playback: StopPlaybackCallback,
|
||||
/// Set volume level (0.0 - 1.0)
|
||||
pub set_volume: SetVolumeCallback,
|
||||
/// Refresh media status from server
|
||||
pub refresh: RefreshCallback,
|
||||
}
|
||||
|
||||
/// Hook to manage media playback state and controls
|
||||
pub fn use_media_control() -> MediaControlState {
|
||||
// Core signals
|
||||
let media_state_signal = RwSignal::new(None::<MediaState>);
|
||||
let loading_signal = RwSignal::new(false);
|
||||
let error_signal = RwSignal::new(None::<String>);
|
||||
|
||||
// Enqueue media
|
||||
let enqueue_impl = Arc::new(move |source: String, mode: String| {
|
||||
spawn_local({
|
||||
let source = source.clone();
|
||||
let mode = mode.clone();
|
||||
async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match media_queue(&source, &mode).await {
|
||||
Ok(state) => {
|
||||
media_state_signal.set(Some(state));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to enqueue media: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Skip to next track
|
||||
let skip_track_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match media_skip().await {
|
||||
Ok(state) => {
|
||||
media_state_signal.set(Some(state));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to skip track: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Stop all playback
|
||||
let stop_playback_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match media_stop().await {
|
||||
Ok(state) => {
|
||||
media_state_signal.set(Some(state));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to stop playback: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Set volume level
|
||||
let set_volume_impl = Arc::new(move |volume: f64| {
|
||||
spawn_local(async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match media_volume(volume).await {
|
||||
Ok(state) => {
|
||||
media_state_signal.set(Some(state));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to set volume: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Refresh media status
|
||||
let refresh_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match get_media_status().await {
|
||||
Ok(state) => {
|
||||
media_state_signal.set(Some(state));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to refresh media status: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
MediaControlState {
|
||||
media_state: media_state_signal,
|
||||
loading: loading_signal,
|
||||
error: error_signal,
|
||||
enqueue: enqueue_impl,
|
||||
skip_track: skip_track_impl,
|
||||
stop_playback: stop_playback_impl,
|
||||
set_volume: set_volume_impl,
|
||||
refresh: refresh_impl,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
/// Callback type for join_voice
|
||||
pub type JoinVoiceCallback = Arc<dyn Fn(String, String) + Send + Sync>;
|
||||
/// Callback type for leave_voice
|
||||
pub type LeaveVoiceCallback = Arc<dyn Fn() + Send + Sync>;
|
||||
/// Callback type for load_guilds
|
||||
pub type LoadGuildsCallback = Arc<dyn Fn() + Send + Sync>;
|
||||
/// Callback type for load_voice_channels
|
||||
pub type LoadVoiceChannelsCallback = Arc<dyn Fn(String) + Send + Sync>;
|
||||
/// Callback type for load_text_channels
|
||||
pub type LoadTextChannelsCallback = Arc<dyn Fn(String) + Send + Sync>;
|
||||
|
||||
/// State returned by use_voice_control hook
|
||||
#[derive(Clone)]
|
||||
pub struct VoiceControlState {
|
||||
/// List of available guilds
|
||||
pub guilds: RwSignal<Vec<Guild>>,
|
||||
/// List of voice channels for current guild
|
||||
pub voice_channels: RwSignal<Vec<Channel>>,
|
||||
/// List of text channels for current guild
|
||||
pub text_channels: RwSignal<Vec<Channel>>,
|
||||
/// Current voice connection status
|
||||
pub voice_status: RwSignal<Option<VoiceStatus>>,
|
||||
/// Whether we're currently loading data
|
||||
pub loading: RwSignal<bool>,
|
||||
/// Last error message if any
|
||||
pub error: RwSignal<Option<String>>,
|
||||
/// Join a voice channel
|
||||
pub join_voice: JoinVoiceCallback,
|
||||
/// Leave the current voice channel
|
||||
pub leave_voice: LeaveVoiceCallback,
|
||||
/// Fetch list of guilds
|
||||
pub load_guilds: LoadGuildsCallback,
|
||||
/// Fetch voice channels for a guild
|
||||
pub load_voice_channels: LoadVoiceChannelsCallback,
|
||||
/// Fetch text channels for a guild
|
||||
pub load_text_channels: LoadTextChannelsCallback,
|
||||
}
|
||||
|
||||
/// Hook to manage voice connection state and controls
|
||||
pub fn use_voice_control() -> VoiceControlState {
|
||||
// Core signals
|
||||
let guilds_signal = RwSignal::new(Vec::<Guild>::new());
|
||||
let voice_channels_signal = RwSignal::new(Vec::<Channel>::new());
|
||||
let text_channels_signal = RwSignal::new(Vec::<Channel>::new());
|
||||
let voice_status_signal = RwSignal::new(None::<VoiceStatus>);
|
||||
let loading_signal = RwSignal::new(false);
|
||||
let error_signal = RwSignal::new(None::<String>);
|
||||
|
||||
// Join a voice channel
|
||||
let join_voice_impl = Arc::new(move |guild_id: String, channel_id: String| {
|
||||
spawn_local({
|
||||
let guild_id = guild_id.clone();
|
||||
let channel_id = channel_id.clone();
|
||||
async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match connect_voice(&guild_id, &channel_id).await {
|
||||
Ok(status) => {
|
||||
voice_status_signal.set(Some(status));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to join voice: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Leave the current voice channel
|
||||
let leave_voice_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match disconnect_voice().await {
|
||||
Ok(status) => {
|
||||
voice_status_signal.set(Some(status));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to leave voice: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch list of guilds
|
||||
let load_guilds_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match get_guilds().await {
|
||||
Ok(guilds) => {
|
||||
guilds_signal.set(guilds);
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to load guilds: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch voice channels for a guild
|
||||
let load_voice_channels_impl = Arc::new(move |guild_id: String| {
|
||||
spawn_local({
|
||||
let guild_id = guild_id.clone();
|
||||
async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match get_voice_channels(&guild_id).await {
|
||||
Ok(channels) => {
|
||||
voice_channels_signal.set(channels);
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to load voice channels: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch text channels for a guild
|
||||
let load_text_channels_impl = Arc::new(move |guild_id: String| {
|
||||
spawn_local({
|
||||
let guild_id = guild_id.clone();
|
||||
async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match get_text_channels(&guild_id).await {
|
||||
Ok(channels) => {
|
||||
text_channels_signal.set(channels);
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to load text channels: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
VoiceControlState {
|
||||
guilds: guilds_signal,
|
||||
voice_channels: voice_channels_signal,
|
||||
text_channels: text_channels_signal,
|
||||
voice_status: voice_status_signal,
|
||||
loading: loading_signal,
|
||||
error: error_signal,
|
||||
join_voice: join_voice_impl,
|
||||
leave_voice: leave_voice_impl,
|
||||
load_guilds: load_guilds_impl,
|
||||
load_voice_channels: load_voice_channels_impl,
|
||||
load_text_channels: load_text_channels_impl,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
pub mod components;
|
||||
pub mod hooks;
|
||||
pub mod audio;
|
||||
|
||||
use leptos::prelude::*;
|
||||
use crate::ws::context::WsContext;
|
||||
use components::{
|
||||
VoiceConnectionCard, ActiveSpeakers, AudioVisualizer,
|
||||
NowPlaying, MusicSubPanel, ScreenSubPanel, RecordingsSubPanel,
|
||||
};
|
||||
|
||||
/// LivePanel — Composition shell for all voice and media components
|
||||
#[component]
|
||||
pub fn LivePanel() -> impl IntoView {
|
||||
let ws = use_context::<WsContext>();
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Recordings */}
|
||||
<RecordingsSubPanel />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::MessageRecord;
|
||||
|
||||
#[component]
|
||||
pub fn ImageGrid(
|
||||
messages: Vec<MessageRecord>,
|
||||
) -> impl IntoView {
|
||||
let mut seen_urls = std::collections::HashSet::new();
|
||||
let mut urls = Vec::new();
|
||||
|
||||
for msg in &messages {
|
||||
if let Some(meta) = &msg.metadata {
|
||||
// attachments with image MIME
|
||||
if let Some(atts) = &meta.attachments {
|
||||
for att in atts {
|
||||
let is_img = att.content_type.as_deref().map(|ct| ct.starts_with("image/")).unwrap_or(false)
|
||||
|| att.name.to_lowercase().ends_with(".png")
|
||||
|| att.name.to_lowercase().ends_with(".jpg")
|
||||
|| att.name.to_lowercase().ends_with(".jpeg")
|
||||
|| att.name.to_lowercase().ends_with(".gif")
|
||||
|| att.name.to_lowercase().ends_with(".webp");
|
||||
if is_img && seen_urls.insert(att.url.clone()) {
|
||||
urls.push(att.url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// stickers
|
||||
if let Some(stickers) = &meta.stickers {
|
||||
for s in stickers {
|
||||
if let Some(ref url) = s.url {
|
||||
if seen_urls.insert(url.clone()) {
|
||||
urls.push(url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// embed images
|
||||
if let Some(embeds) = &meta.embeds {
|
||||
for e in embeds {
|
||||
if let Some(ref img) = e.image {
|
||||
if seen_urls.insert(img.url.clone()) {
|
||||
urls.push(img.url.clone());
|
||||
}
|
||||
}
|
||||
if let Some(ref thumb) = e.thumbnail {
|
||||
if seen_urls.insert(thumb.url.clone()) {
|
||||
urls.push(thumb.url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if urls.is_empty() {
|
||||
return view! {
|
||||
<div class="flex items-center justify-center h-32 text-secondary italic">
|
||||
"No images found"
|
||||
</div>
|
||||
}.into_any();
|
||||
}
|
||||
|
||||
view! {
|
||||
<div class="image-grid">
|
||||
{urls.into_iter().map(|url| {
|
||||
let url_clone = url.clone();
|
||||
view! {
|
||||
<a href=url_clone target="_blank" class="image-grid-item">
|
||||
<img src=url alt="attachment" loading="lazy" />
|
||||
</a>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
use leptos::prelude::*;
|
||||
use regex::Regex;
|
||||
use shared_types::message::{AiSeverity, AiStatus, AttachmentRef, MessageRecord};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
|
||||
fn custom_emoji_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"<(a)?:([a-zA-Z0-9_]+):(\d+)>").unwrap())
|
||||
}
|
||||
|
||||
fn render_emojis(content: &str) -> Vec<AnyView> {
|
||||
let re = custom_emoji_regex();
|
||||
let mut parts: Vec<AnyView> = Vec::new();
|
||||
let mut last = 0;
|
||||
let content_owned = content.to_string();
|
||||
for cap in re.captures_iter(&content_owned) {
|
||||
let m = cap.get(0).unwrap();
|
||||
if m.start() > last {
|
||||
let text = content_owned[last..m.start()].to_string();
|
||||
parts.push(view! { <span>{text}</span> }.into_any());
|
||||
}
|
||||
let animated = cap.get(1).is_some();
|
||||
let name = cap.get(2).map(|c| c.as_str()).unwrap_or("").to_string();
|
||||
let id = cap.get(3).map(|c| c.as_str()).unwrap_or("0").to_string();
|
||||
let ext = if animated { "gif" } else { "png" };
|
||||
let url = format!("https://cdn.discordapp.com/emojis/{}.{}?size=128", id, ext);
|
||||
let title = format!(":{}:", name);
|
||||
parts.push(view! {
|
||||
<img src=url alt=name class="custom-emoji" title=title loading="lazy" />
|
||||
}.into_any());
|
||||
last = m.end();
|
||||
}
|
||||
if last < content_owned.len() {
|
||||
let text = content_owned[last..].to_string();
|
||||
parts.push(view! { <span>{text}</span> }.into_any());
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn time_ago(ts: i64) -> String {
|
||||
let now = (js_sys::Date::now() / 1000.0) as i64;
|
||||
let secs = if now > ts { now - ts } else { 0 };
|
||||
if secs < 60 {
|
||||
format!("{}s ago", secs)
|
||||
} else if secs < 3600 {
|
||||
format!("{}m ago", secs / 60)
|
||||
} else if secs < 86400 {
|
||||
format!("{}h ago", secs / 3600)
|
||||
} else {
|
||||
let d = js_sys::Date::new(&JsValue::from_f64((ts as f64) * 1000.0));
|
||||
format!("{}", d.to_locale_date_string("en-US", &JsValue::UNDEFINED))
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_time(ts: i64) -> String {
|
||||
let d = js_sys::Date::new(&JsValue::from_f64((ts as f64) * 1000.0));
|
||||
format!("{:02}:{:02}", d.get_hours(), d.get_minutes())
|
||||
}
|
||||
|
||||
fn severity_class(s: &AiSeverity) -> &'static str {
|
||||
match s {
|
||||
AiSeverity::Critical | AiSeverity::High => "badge-destructive",
|
||||
AiSeverity::Medium => "badge-warning",
|
||||
AiSeverity::Low => "badge-info",
|
||||
AiSeverity::None => "badge-outline",
|
||||
}
|
||||
}
|
||||
|
||||
fn is_fallback(t: &str) -> bool {
|
||||
t.starts_with("[Attachment:")
|
||||
|| t.starts_with("[Sticker:")
|
||||
|| t.starts_with("[Embed]")
|
||||
}
|
||||
|
||||
fn get_cats(raw: &Option<Vec<String>>) -> Vec<String> {
|
||||
raw.as_ref()
|
||||
.map(|v| v.iter().filter(|c| *c != "analysis_incomplete").cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ─── StatusBadgeInline ────────────────────────────────────
|
||||
#[component]
|
||||
fn StatusBadgeInline(status: AiStatus) -> impl IntoView {
|
||||
let (cl, icon_svg): (&'static str, AnyView) = match &status {
|
||||
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()),
|
||||
};
|
||||
view! {
|
||||
<span class=format!("status-badge {}", cl)>
|
||||
{icon_svg}
|
||||
{format!("{:?}", status)}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MessageRow ───────────────────────────────────────────
|
||||
#[component]
|
||||
pub fn MessageRow(
|
||||
message: MessageRecord,
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let cats = get_cats(&message.ai_categories);
|
||||
let conf = message.ai_confidence.or(message.ai_moderation_score);
|
||||
let display = message.edited_content.as_deref().unwrap_or(&message.content);
|
||||
let show = !display.is_empty() && !is_fallback(display);
|
||||
let ai_st = message.ai_status.clone().unwrap_or(AiStatus::Pending);
|
||||
|
||||
let analysis_summary = {
|
||||
let mut p = cats.iter().take(3).cloned().collect::<Vec<_>>().join(", ");
|
||||
if cats.len() > 3 {
|
||||
p = format!("{} +{} more", p, cats.len() - 3);
|
||||
}
|
||||
if !p.is_empty() { p.push_str(" · "); }
|
||||
p.push_str(&format!("{}% conf", conf.map(|c| (c * 100.0) as u8).unwrap_or(0)));
|
||||
p
|
||||
};
|
||||
|
||||
// Attachments
|
||||
let all_atts = message.metadata.as_ref()
|
||||
.and_then(|m| m.attachments.as_ref()).cloned().unwrap_or_default();
|
||||
let imgs: Vec<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 reanalyze_id = message.id.clone();
|
||||
let on_click_re = move |_| on_reanalyze(reanalyze_id.clone());
|
||||
|
||||
view! {
|
||||
<div class="message-row">
|
||||
{/* Header */}
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span class="message-timestamp" title=time_ago(message.created_at)>
|
||||
{fmt_time(message.created_at)}
|
||||
</span>
|
||||
{message.edited_at.is_some().then(|| view! {
|
||||
<span class="flex items-center gap-0.5 text-xs text-secondary">
|
||||
"✎ edited"
|
||||
</span>
|
||||
})}
|
||||
{message.deleted_at.is_some().then(|| view! {
|
||||
<span class="flex items-center gap-0.5 text-xs text-destructive">
|
||||
"🗑 deleted"
|
||||
</span>
|
||||
})}
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<StatusBadgeInline status=ai_st.clone() />
|
||||
{message.ai_severity.as_ref().filter(|s| **s != AiSeverity::None).map(|sev| view! {
|
||||
<span class=format!("badge text-xs {}", severity_class(sev))>{format!("{:?}", sev)}</span>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reply - field removed from MessageRecord */}
|
||||
|
||||
{/* Forward - field removed from MessageRecord */}
|
||||
|
||||
{/* Crosspost - field removed from MessageRecord */}
|
||||
|
||||
{/* Content */}
|
||||
{show.then(|| {
|
||||
let rendered = render_emojis(display);
|
||||
let cls_str = if message.deleted_at.is_some() { "text-secondary/60" } else { "" };
|
||||
let class_str = format!("whitespace-pre-wrap break-words text-sm leading-6 {}", cls_str);
|
||||
view! {
|
||||
<p class=class_str>
|
||||
{rendered.into_iter().collect::<Vec<_>>()}
|
||||
</p>
|
||||
}
|
||||
})}
|
||||
|
||||
{/* Stickers */}
|
||||
{(!stickers.is_empty()).then(|| view! {
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{stickers.iter().map(|s| {
|
||||
let url_owned = s.url.clone().unwrap_or_default();
|
||||
let name_owned = s.name.clone().unwrap_or_default();
|
||||
let has_url = !url_owned.is_empty();
|
||||
view! {
|
||||
<div>
|
||||
{if has_url {
|
||||
view! {
|
||||
<img src=url_owned alt=name_owned class="h-12 w-12 rounded-lg border border-border object-contain bg-surface/50" loading="lazy" />
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-surface/50">
|
||||
"😊"
|
||||
</div>
|
||||
}.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
})}
|
||||
|
||||
{/* Images */}
|
||||
{if !imgs.is_empty() {
|
||||
let imgs_local = imgs.clone();
|
||||
let images_view = imgs_local.iter().take(4).map(|a| {
|
||||
let url1 = a.url.clone();
|
||||
let url2 = a.url.clone();
|
||||
let name1 = a.name.clone();
|
||||
view! {
|
||||
<a href=url1 target="_blank" class="shrink-0 overflow-hidden rounded-lg border border-border">
|
||||
<img src=url2 alt=name1 class="h-16 w-16 object-cover hover:scale-105 transition-transform" loading="lazy" />
|
||||
</a>
|
||||
}
|
||||
}).collect::<Vec<_>>();
|
||||
let overflow = if imgs.len() > 4 {
|
||||
let extra = imgs.len() - 4;
|
||||
view! {
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-lg border border-border bg-surface text-xs text-secondary">
|
||||
<span>{"+"} {extra}</span> <span class="ml-0.5">"🖼"</span>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
};
|
||||
view! {
|
||||
<div class="flex gap-2 overflow-x-auto">
|
||||
{images_view}
|
||||
{overflow}
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
|
||||
{/* Videos */}
|
||||
{if !vids.is_empty() {
|
||||
let vids_local = vids.clone();
|
||||
let videos_view = vids_local.iter().take(4).map(|a| {
|
||||
let url = a.url.clone();
|
||||
view! {
|
||||
<video src=url controls class="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-black" preload="metadata"></video>
|
||||
}
|
||||
}).collect::<Vec<_>>();
|
||||
let overflow = if vids.len() > 4 {
|
||||
let extra = vids.len() - 4;
|
||||
view! {
|
||||
<div class="flex h-28 w-16 items-center justify-center rounded-lg border border-border bg-surface text-xs text-secondary">
|
||||
<span>{"+"} {extra}</span> <span class="ml-0.5">"▶"</span>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
};
|
||||
view! {
|
||||
<div class="flex gap-2 overflow-x-auto">
|
||||
{videos_view}
|
||||
{overflow}
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
|
||||
{/* Categories */}
|
||||
{if !cats.is_empty() {
|
||||
let cats_local = cats.clone();
|
||||
view! {
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{cats_local.iter().map(|c| view! {
|
||||
<span class="badge badge-secondary text-xs">{c.clone()}</span>
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
|
||||
{/* AI Analysis */}
|
||||
{message.ai_analysis.as_ref().map(|analysis| {
|
||||
let border_str = if ai_st == AiStatus::Flagged { "border-l-3 bg-warning/5" } else { "border-l-3 bg-success/5" };
|
||||
let icon = if ai_st == AiStatus::Flagged { "🚨" } else { "ℹ️" };
|
||||
let analysis_summary_str = analysis_summary.clone();
|
||||
let analysis_str = analysis.clone();
|
||||
view! {
|
||||
<div class=format!("rounded-lg px-3 py-2 {}", border_str)>
|
||||
<div class="flex items-start gap-2 text-xs">
|
||||
<span class="mt-0.5 shrink-0">{icon}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block font-medium mb-1">{analysis_summary_str}</span>
|
||||
<div class="text-xs leading-relaxed whitespace-pre-wrap">{analysis_str}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{/* Error */}
|
||||
{message.ai_error.as_ref().map(|e| {
|
||||
let error_str = e.clone();
|
||||
view! {
|
||||
<div class="rounded-lg bg-warning/5 px-3 py-2 text-xs text-warning">
|
||||
<span>"AI error: "{error_str}</span>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{/* Re-analyze */}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class=format!("btn btn-sm {}", if ai_st == AiStatus::Error { "btn-destructive" } else { "btn-outline" })
|
||||
on:click=on_click_re
|
||||
disabled=ai_st == AiStatus::Processing
|
||||
>
|
||||
<svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path></svg>
|
||||
" Re-analyze"
|
||||
</button>
|
||||
{(ai_st == AiStatus::Error).then(|| view! {
|
||||
<span class="text-xs text-secondary/70">"Click to retry"</span>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MessageCard ──────────────────────────────────────────
|
||||
#[component]
|
||||
pub fn MessageCard(
|
||||
messages: Vec<MessageRecord>,
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let first = &messages[0];
|
||||
let has_multi = messages.len() > 1;
|
||||
let deleted = first.deleted_at.is_some();
|
||||
let avatar = first.avatar_url.clone()
|
||||
.unwrap_or_else(|| "https://cdn.discordapp.com/embed/avatars/0.png".into());
|
||||
let loc_label = first.metadata.as_ref().and_then(|m| m.channel.as_ref()).map(|c| {
|
||||
if let Some(ref tn) = c.thread_name {
|
||||
format!("# {} › {}", c.channel_name.as_deref().unwrap_or("?"), tn)
|
||||
} else {
|
||||
format!("# {}", c.channel_name.as_deref().unwrap_or("?"))
|
||||
}
|
||||
});
|
||||
let card_cls = if deleted { "border-destructive/20 opacity-60" } else { "" };
|
||||
|
||||
view! {
|
||||
<article class=format!("message-card shadow-sm transition-all {}", card_cls)>
|
||||
<div class="flex gap-3 p-4">
|
||||
<img src=avatar alt="" class="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-baseline gap-2 mb-2">
|
||||
<span class="font-semibold text-sm">{first.username.clone()}</span>
|
||||
{loc_label.as_ref().map(|l| {
|
||||
let label_str = l.clone();
|
||||
view! {
|
||||
<span class="flex items-center gap-1 text-xs text-secondary/50 bg-surface/50 px-1.5 py-0.5 rounded-full">
|
||||
"#" " " {label_str}
|
||||
</span>
|
||||
}
|
||||
})}
|
||||
<span class="text-xs text-secondary/60">
|
||||
{time_ago(first.created_at)}
|
||||
{has_multi.then(|| format!(" · {} msgs", messages.len()))}
|
||||
</span>
|
||||
</div>
|
||||
<div class=if has_multi { "space-y-2.5" } else { "" }>
|
||||
{messages.into_iter().enumerate().map(|(i, msg)| {
|
||||
let sep = has_multi && i > 0;
|
||||
view! {
|
||||
<div class=if sep { "pt-2.5 border-t border-border/30" } else { "" }>
|
||||
<MessageRow message=msg on_reanalyze=on_reanalyze.clone() />
|
||||
</div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Skeleton ─────────────────────────────────────────────
|
||||
#[component]
|
||||
pub fn MessageCardSkeleton() -> impl IntoView {
|
||||
view! {
|
||||
<article class="message-card">
|
||||
<div class="flex gap-3 p-4">
|
||||
<div class="skeleton skeleton-circular" style="width:40px;height:40px"></div>
|
||||
<div class="min-w-0 flex-1 space-y-3">
|
||||
<div class="skeleton" style="height:20px;width:192px"></div>
|
||||
<div class="skeleton" style="height:16px;width:100%"></div>
|
||||
<div class="skeleton" style="height:16px;width:75%"></div>
|
||||
<div class="flex gap-2">
|
||||
<div class="skeleton" style="height:24px;width:64px;border-radius:9999px"></div>
|
||||
<div class="skeleton" style="height:24px;width:80px;border-radius:9999px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::MessageRecord;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::IntersectionObserver;
|
||||
use leptos::html;
|
||||
|
||||
const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000;
|
||||
|
||||
fn group_messages(messages: Vec<MessageRecord>) -> Vec<Vec<MessageRecord>> {
|
||||
let mut groups: Vec<Vec<MessageRecord>> = Vec::new();
|
||||
for msg in messages {
|
||||
if let Some(last_group) = groups.last_mut() {
|
||||
let same_user = last_group.first()
|
||||
.map(|m| m.user_id == msg.user_id)
|
||||
.unwrap_or(false);
|
||||
let same_window = last_group.last()
|
||||
.map(|m| (m.created_at - msg.created_at).abs() < GROUP_WINDOW_MS)
|
||||
.unwrap_or(false);
|
||||
if same_user && same_window {
|
||||
last_group.push(msg);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
groups.push(vec![msg]);
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MessageFeed(
|
||||
messages: Vec<MessageRecord>,
|
||||
#[prop(optional)] empty_text: &'static str,
|
||||
#[prop(optional)] loading: bool,
|
||||
#[prop(optional)] has_more: bool,
|
||||
#[prop(optional)] loading_more: bool,
|
||||
#[prop(optional)] on_load_more: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let sentinel_ref = create_node_ref::<html::Div>();
|
||||
let (intersecting, set_intersecting) = create_signal(false);
|
||||
|
||||
create_effect(move |_| {
|
||||
let _ = intersecting.get(); // track signal
|
||||
if let Some(node) = sentinel_ref.get() {
|
||||
let 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 observer = IntersectionObserver::new(cb.as_ref().unchecked_ref())
|
||||
.expect("IntersectionObserver failed");
|
||||
observer.observe(&node);
|
||||
on_cleanup(move || {
|
||||
observer.disconnect();
|
||||
});
|
||||
// Keep closure alive
|
||||
cb.forget();
|
||||
}
|
||||
});
|
||||
|
||||
// Loading state
|
||||
if loading {
|
||||
return view! {
|
||||
<div class="space-y-4">
|
||||
{std::iter::repeat_with(|| {
|
||||
use super::message_card::MessageCardSkeleton;
|
||||
view! { <MessageCardSkeleton /> }
|
||||
}).take(3).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any();
|
||||
}
|
||||
|
||||
if messages.is_empty() {
|
||||
return view! {
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">
|
||||
{if empty_text.is_empty() { "No messages" } else { empty_text }}
|
||||
</div>
|
||||
</div>
|
||||
}.into_any();
|
||||
}
|
||||
|
||||
let groups = group_messages(messages);
|
||||
let has_more_val = has_more;
|
||||
let loading_more_val = loading_more;
|
||||
|
||||
view! {
|
||||
<div class="space-y-4">
|
||||
{groups.into_iter().map(|group| {
|
||||
let cb = on_reanalyze.clone();
|
||||
view! {
|
||||
<MessageCardGroup messages=group on_reanalyze=cb.clone() />
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
|
||||
{/* Infinite scroll sentinel */}
|
||||
{has_more_val.then(|| {
|
||||
view! {
|
||||
<div node_ref=sentinel_ref class="h-4">
|
||||
{loading_more_val.then(|| {
|
||||
use super::message_card::MessageCardSkeleton;
|
||||
view! { <MessageCardSkeleton /> }
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn MessageCardGroup(
|
||||
messages: Vec<MessageRecord>,
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
use super::message_card::MessageCard;
|
||||
view! {
|
||||
<MessageCard messages=messages on_reanalyze=on_reanalyze />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod message_feed;
|
||||
pub mod message_card;
|
||||
pub mod image_grid;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod use_messages;
|
||||
@@ -0,0 +1,200 @@
|
||||
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();
|
||||
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
|
||||
}
|
||||
|
||||
/// Callback type for fetch_messages
|
||||
pub type FetchMessagesCallback = Arc<dyn Fn(String) + Send + Sync>;
|
||||
/// Callback type for load_more
|
||||
pub type LoadMoreCallback = Arc<dyn Fn() + Send + Sync>;
|
||||
/// Callback type for reanalyze
|
||||
pub type ReanalyzeCallback = Arc<dyn Fn(String) + Send + Sync>;
|
||||
/// Callback type for reanalyze_all_errors
|
||||
pub type ReanalyzeAllErrorsCallback = Arc<dyn Fn() + Send + Sync>;
|
||||
|
||||
/// State returned by use_messages hook
|
||||
#[derive(Clone)]
|
||||
pub struct MessagesState {
|
||||
/// Current list of messages
|
||||
pub messages: RwSignal<Vec<MessageRecord>>,
|
||||
/// Whether the initial fetch is in progress
|
||||
pub loading: ReadSignal<bool>,
|
||||
/// Whether we're loading more messages
|
||||
pub loading_more: RwSignal<bool>,
|
||||
/// Pagination cursor for next page
|
||||
pub cursor: RwSignal<Option<String>>,
|
||||
/// Derived: whether there are more messages to load
|
||||
pub has_more: Memo<bool>,
|
||||
/// Last error message if any
|
||||
pub error: RwSignal<Option<String>>,
|
||||
/// Current guild ID
|
||||
pub current_guild: RwSignal<Option<String>>,
|
||||
/// Fetch initial messages for a guild
|
||||
pub fetch_messages: FetchMessagesCallback,
|
||||
/// Load next page of messages
|
||||
pub load_more: LoadMoreCallback,
|
||||
/// Reanalyze a single message
|
||||
pub reanalyze: ReanalyzeCallback,
|
||||
/// Reanalyze all error messages in current batch
|
||||
pub reanalyze_all_errors: ReanalyzeAllErrorsCallback,
|
||||
}
|
||||
|
||||
/// Hook to manage message data fetching and state
|
||||
pub fn use_messages() -> MessagesState {
|
||||
// Core signals
|
||||
let messages_signal = RwSignal::new(Vec::<MessageRecord>::new());
|
||||
let (loading, set_loading) = create_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());
|
||||
|
||||
// Fetch initial messages for a guild
|
||||
let fetch_messages_impl = Arc::new(move |guild_id: String| {
|
||||
spawn_local({
|
||||
let guild_id = guild_id.clone();
|
||||
async move {
|
||||
error_signal.set(None);
|
||||
set_loading.set(true);
|
||||
|
||||
match get_messages(&guild_id, Some(30), None, None).await {
|
||||
Ok(PageResult { data, next_cursor }) => {
|
||||
messages_signal.set(data);
|
||||
cursor_signal.set(next_cursor);
|
||||
current_guild_signal.set(Some(guild_id));
|
||||
set_loading.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to fetch messages: {}", e)));
|
||||
set_loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Load more messages (append next page)
|
||||
let load_more_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
let guild_id = match current_guild_signal.get() {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
error_signal.set(Some("No guild selected".to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let cursor = match cursor_signal.get() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
error_signal.set(Some("No more messages to load".to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
loading_more_signal.set(true);
|
||||
error_signal.set(None);
|
||||
|
||||
match get_messages(&guild_id, Some(30), None, Some(&cursor)).await {
|
||||
Ok(PageResult { data, next_cursor }) => {
|
||||
let current = messages_signal.get();
|
||||
messages_signal.set(merge_messages(¤t, &data));
|
||||
cursor_signal.set(next_cursor);
|
||||
loading_more_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to load more: {}", e)));
|
||||
loading_more_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reanalyze single message with optimistic update
|
||||
let reanalyze_impl = Arc::new(move |message_id: String| {
|
||||
spawn_local({
|
||||
let message_id = message_id.clone();
|
||||
async move {
|
||||
// Optimistic: flip status to Processing
|
||||
let mut msgs = messages_signal.get();
|
||||
if let Some(pos) = msgs.iter().position(|m| m.id == message_id) {
|
||||
if let Some(ref mut msg) = msgs.get_mut(pos) {
|
||||
msg.ai_status = Some(shared_types::message::AiStatus::Processing);
|
||||
}
|
||||
}
|
||||
messages_signal.set(msgs);
|
||||
|
||||
// Call API
|
||||
match reanalyze_message(&message_id).await {
|
||||
Ok(_) => {
|
||||
// Success: keep the Processing status (will be updated via WS)
|
||||
}
|
||||
Err(e) => {
|
||||
// Revert to Error status on failure
|
||||
let mut msgs = messages_signal.get();
|
||||
if let Some(pos) = msgs.iter().position(|m| m.id == message_id) {
|
||||
if let Some(ref mut msg) = msgs.get_mut(pos) {
|
||||
msg.ai_status = Some(shared_types::message::AiStatus::Error);
|
||||
msg.ai_error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
messages_signal.set(msgs);
|
||||
error_signal.set(Some(format!("Reanalyze failed: {}", e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reanalyze all error messages
|
||||
let reanalyze_all_errors_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
match reanalyze_batch().await {
|
||||
Ok(_count) => {
|
||||
error_signal.set(None);
|
||||
// Optimistically mark all error messages as Processing
|
||||
let mut msgs = messages_signal.get();
|
||||
for msg in msgs.iter_mut() {
|
||||
if msg.ai_status == Some(shared_types::message::AiStatus::Error) {
|
||||
msg.ai_status = Some(shared_types::message::AiStatus::Processing);
|
||||
}
|
||||
}
|
||||
messages_signal.set(msgs);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Batch reanalyze failed: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
MessagesState {
|
||||
messages: messages_signal,
|
||||
loading,
|
||||
loading_more: loading_more_signal,
|
||||
cursor: cursor_signal,
|
||||
has_more: has_more_signal,
|
||||
error: error_signal,
|
||||
current_guild: current_guild_signal,
|
||||
fetch_messages: fetch_messages_impl,
|
||||
load_more: load_more_impl,
|
||||
reanalyze: reanalyze_impl,
|
||||
reanalyze_all_errors: reanalyze_all_errors_impl,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::{AiStatus, MessageRecord};
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
pub mod components;
|
||||
pub mod hooks;
|
||||
|
||||
use components::message_feed::MessageFeed;
|
||||
use components::image_grid::ImageGrid;
|
||||
use hooks::use_messages::{merge_messages, use_messages};
|
||||
|
||||
type AiFilter = &'static str;
|
||||
const FILTERS: &[AiFilter] = &["all", "analyzed", "clean", "flagged", "error", "pending"];
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum ViewTab { All, Images }
|
||||
|
||||
#[component]
|
||||
pub fn MessagesPanel() -> impl IntoView {
|
||||
let state = use_messages();
|
||||
let (search_query, set_search_query) = create_signal(String::new());
|
||||
let (search_results, set_search_results) = create_signal::<Vec<MessageRecord>>(Vec::new());
|
||||
let (show_search, set_show_search) = create_signal(false);
|
||||
let (is_searching, set_is_searching) = create_signal(false);
|
||||
let ai_filter = RwSignal::new("analyzed".to_string());
|
||||
let view_tab = RwSignal::new(ViewTab::All);
|
||||
let (retrying_all, set_retrying_all) = create_signal(false);
|
||||
|
||||
// Stats derived from filtered messages
|
||||
let stats = create_memo(move |_| {
|
||||
let base = if show_search.get() { search_results.get() } else { state.messages.get() };
|
||||
let total = base.len();
|
||||
let clean = base.iter().filter(|m| m.ai_status == Some(AiStatus::Clean)).count();
|
||||
let flagged = base.iter().filter(|m| m.ai_status == Some(AiStatus::Flagged)).count();
|
||||
let error = base.iter().filter(|m| m.ai_status == Some(AiStatus::Error)).count();
|
||||
let pending = base.iter().filter(|m| m.ai_status.is_none() || m.ai_status == Some(AiStatus::Pending)).count();
|
||||
let deleted = base.iter().filter(|m| m.deleted_at.is_some()).count();
|
||||
let edited = base.iter().filter(|m| m.edited_at.is_some()).count();
|
||||
(total, clean, flagged, error, pending, deleted, edited)
|
||||
});
|
||||
|
||||
// Filter messages based on active filter
|
||||
let filtered_messages = create_memo(move |_| {
|
||||
let base = if show_search.get() { search_results.get() } else { state.messages.get() };
|
||||
let filter = ai_filter.get();
|
||||
if filter == "all" { return base; }
|
||||
base.into_iter().filter(|m| {
|
||||
let status = m.ai_status.clone().unwrap_or(AiStatus::Pending);
|
||||
if filter == "analyzed" { return status != AiStatus::Pending; }
|
||||
if filter == "pending" { return status == AiStatus::Pending; }
|
||||
format!("{:?}", status).to_lowercase() == filter
|
||||
}).collect()
|
||||
});
|
||||
|
||||
// Search handler - takes any event type and triggers the search
|
||||
let do_search = {
|
||||
let q = search_query;
|
||||
move || {
|
||||
let query = q.get();
|
||||
if query.trim().is_empty() {
|
||||
set_show_search.set(false);
|
||||
set_search_results.set(Vec::new());
|
||||
return;
|
||||
}
|
||||
set_is_searching.set(true);
|
||||
let q_clone = query.trim().to_string();
|
||||
spawn_local(async move {
|
||||
match crate::api::messages::search_messages(&q_clone, Some(50)).await {
|
||||
Ok(results) => {
|
||||
set_search_results.set(results);
|
||||
set_show_search.set(true);
|
||||
}
|
||||
Err(_) => {
|
||||
set_search_results.set(Vec::new());
|
||||
}
|
||||
}
|
||||
set_is_searching.set(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
// Separate closures for different event types so on:click/on:keydown type-check
|
||||
let handle_search_click = move |_: web_sys::MouseEvent| do_search();
|
||||
let handle_search_keydown = move |_: web_sys::KeyboardEvent| do_search();
|
||||
|
||||
// Clear search
|
||||
let clear_search = move |_| {
|
||||
set_show_search.set(false);
|
||||
set_search_results.set(Vec::new());
|
||||
set_search_query.set(String::new());
|
||||
};
|
||||
|
||||
// Reanalyze all errors
|
||||
let handle_retry_all = move |_| {
|
||||
set_retrying_all.set(true);
|
||||
let cb = state.reanalyze_all_errors.clone();
|
||||
spawn_local(async move {
|
||||
cb();
|
||||
set_retrying_all.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
// Filter chip click
|
||||
let set_filter = {
|
||||
let af = ai_filter;
|
||||
move |f: &'static str| af.set(f.to_string())
|
||||
};
|
||||
|
||||
// WS event handlers (wire once on mount)
|
||||
let ws = use_context::<crate::ws::context::WsContext>();
|
||||
if let Some(ref ws) = ws {
|
||||
// Subscribe to real-time message events
|
||||
{
|
||||
let msgs = state.messages;
|
||||
*ws.on_message_created.borrow_mut() = Some(Box::new(move |msg| {
|
||||
let current = msgs.get();
|
||||
msgs.set(merge_messages(¤t, &[msg]));
|
||||
}));
|
||||
}
|
||||
{
|
||||
let msgs = state.messages;
|
||||
*ws.on_message_updated.borrow_mut() = Some(Box::new(move |msg| {
|
||||
let current = msgs.get();
|
||||
msgs.set(merge_messages(¤t, &[msg]));
|
||||
}));
|
||||
}
|
||||
{
|
||||
let msgs = state.messages;
|
||||
*ws.on_message_deleted.borrow_mut() = Some(Box::new(move |id| {
|
||||
let current = msgs.get();
|
||||
msgs.set(current.into_iter().filter(|m| m.id != id).collect());
|
||||
}));
|
||||
}
|
||||
{
|
||||
let msgs = state.messages;
|
||||
*ws.on_message_analyzed.borrow_mut() = Some(Box::new(move |msg| {
|
||||
let current = msgs.get();
|
||||
msgs.set(merge_messages(¤t, &[msg]));
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch messages on mount if guild is configured
|
||||
create_effect(move |_| {
|
||||
if let Some(config) = use_context::<crate::app::AppConfig>() {
|
||||
if let Some(ref guild_id) = config.monitor_guild_id {
|
||||
(state.fetch_messages)(guild_id.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── View ────────────────────────────────────────────────
|
||||
let get_stats = move || stats.get();
|
||||
let (total, clean, flagged, error, pending, deleted, edited) = (
|
||||
move || get_stats().0,
|
||||
move || get_stats().1,
|
||||
move || get_stats().2,
|
||||
move || get_stats().3,
|
||||
move || get_stats().4,
|
||||
move || get_stats().5,
|
||||
move || get_stats().6,
|
||||
);
|
||||
|
||||
view! {
|
||||
<div class="messages-panel">
|
||||
{/* Header card */}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Messages"</div>
|
||||
<p class="card-description">
|
||||
"Messages are automatically captured from all text channels. Real-time updates arrive via WebSocket."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats badges */}
|
||||
{(total() > 0).then(|| view! {
|
||||
<div class="message-stats">
|
||||
<span class="badge badge-outline text-xs">{total()} " total" {state.has_more.get().then(|| "+")}</span>
|
||||
<span class="badge badge-success text-xs">{clean()} " clean"</span>
|
||||
<span class="badge badge-primary text-xs">{flagged()} " flagged"</span>
|
||||
<span class="badge badge-warning text-xs">{error()} " error"</span>
|
||||
<span class="badge badge-outline text-xs">{pending()} " pending"</span>
|
||||
{(deleted() > 0).then(|| view! {
|
||||
<span class="badge badge-destructive text-xs">{deleted()} " deleted"</span>
|
||||
})}
|
||||
{(edited() > 0).then(|| view! {
|
||||
<span class="badge badge-outline text-xs">{edited()} " edited"</span>
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
|
||||
{/* Search + filters row */}
|
||||
<div class="search-row">
|
||||
<div class="relative flex-1" style="min-width:200px">
|
||||
{/* Search icon as SVG */}
|
||||
<svg class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.35-4.35"></path></svg>
|
||||
<input
|
||||
class="input"
|
||||
style="padding-left:2.25rem;border-radius:9999px"
|
||||
placeholder="Search message content..."
|
||||
prop:value=search_query
|
||||
on:input=move |ev| set_search_query.set(event_target_value(&ev))
|
||||
on:keydown=move |ev| {
|
||||
if ev.key() == "Enter" { handle_search_keydown(ev); }
|
||||
}
|
||||
disabled=move || is_searching.get()
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
on:click=handle_search_click
|
||||
disabled=move || is_searching.get() || search_query.get().trim().is_empty()
|
||||
>
|
||||
{move || if is_searching.get() { "Searching..." } else { "Search" }}
|
||||
</button>
|
||||
{show_search.get().then(|| view! {
|
||||
<button class="btn btn-outline btn-sm" on:click=clear_search>
|
||||
"✕ Clear"
|
||||
</button>
|
||||
})}
|
||||
{(error() > 0 && !show_search.get()).then(|| view! {
|
||||
<button
|
||||
class="btn btn-destructive btn-sm"
|
||||
on:click=handle_retry_all
|
||||
disabled=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">
|
||||
{/* Filter icon as SVG since lucide-leptos Filter unavailable */}
|
||||
<svg class="h-4 w-4 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
|
||||
{FILTERS.iter().map(|f| {
|
||||
let active = ai_filter.get() == *f;
|
||||
let cls = if active {
|
||||
"filter-chip active"
|
||||
} else {
|
||||
"filter-chip"
|
||||
};
|
||||
let f_ptr: &'static str = f;
|
||||
view! {
|
||||
<button class=cls on:click=move |_| set_filter(f_ptr) >{*f}</button>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search results count */}
|
||||
{show_search.get().then(|| {
|
||||
let n = search_results.get().len();
|
||||
view! {
|
||||
<div class="text-sm text-secondary">
|
||||
"Found " {n} " result" {if n != 1 { "s" } else { "" }}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{/* View tabs + content */}
|
||||
<div class="tabs">
|
||||
<div class="tab-list">
|
||||
<button
|
||||
class="tab-trigger"
|
||||
class:active=move || view_tab.get() == ViewTab::All
|
||||
on:click=move |_| view_tab.set(ViewTab::All)
|
||||
aria-selected=move || if view_tab.get() == ViewTab::All { "true" } else { "false" }
|
||||
>
|
||||
{move || {
|
||||
let label = if show_search.get() { "Search" } else { "All" };
|
||||
format!("{} ({})", label, filtered_messages.with(|m| m.len()))
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
class="tab-trigger"
|
||||
class:active=move || view_tab.get() == ViewTab::Images
|
||||
on:click=move |_| view_tab.set(ViewTab::Images)
|
||||
aria-selected=move || if view_tab.get() == ViewTab::Images { "true" } else { "false" }
|
||||
>
|
||||
"Images"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" style:display=move || if view_tab.get() == ViewTab::All { "block" } else { "none" }>
|
||||
{
|
||||
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() };
|
||||
let on_load_more_clone: Arc<dyn Fn() + Send + Sync + 'static> = Arc::new(move || load_more_cb());
|
||||
view! {
|
||||
<MessageFeed
|
||||
messages=filtered_messages.get()
|
||||
empty_text=empty_text
|
||||
loading=state.loading.get()
|
||||
has_more=has_more
|
||||
loading_more=state.loading_more.get()
|
||||
on_load_more=on_load_more_clone
|
||||
on_reanalyze=state.reanalyze.clone()
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="tab-content" style:display=move || if view_tab.get() == ViewTab::Images { "block" } else { "none" }>
|
||||
<ImageGrid messages=filtered_messages.get() />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user