feat: migrate Leptos frontend to Next.js 16 (React 19)
Deploy to VPS / deploy (push) Failing after 42s

Complete migration from services/frontend.old/ (Leptos 0.7 WASM + Rust)
to services/frontend/ (Next.js 16 static export + TypeScript + Tailwind v4).

Summary:
- Port all shared types (message, guild, voice, media, dashboard, recording, ui)
- Build fetch-based API client covering all 30+ backend endpoints
- WebSocket client with auto-reconnect (exponential backoff, 20 attempts)
- React context provider for WS with typed event subscription (22 event types)
- Login page with localStorage auth + auto-redirect
- Dashboard layout with sidebar, header (WS status + theme toggle)
- Messages: feed, search, images tab, review tab, channel filter, detail modal
- Live: voice connection, music player, recordings, mic transmit, active speakers
- Dashboard: stats, user list, channel list, detail views
- Mascot chatbot with history + clear
- uiStateApi persistence for selected tab
- Add static export config, update deploy scripts and CI
This commit is contained in:
asepharyana
2026-07-26 11:27:21 +07:00
parent cbbe939fad
commit 5e01ec0806
165 changed files with 5035 additions and 16410 deletions
-8
View File
@@ -47,14 +47,6 @@ jobs:
version: 11.1.3
run_install: false
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Install Trunk
run: cargo install trunk --version 0.22.0-beta.1 --locked
- name: Install dependencies
run: pnpm install --frozen-lockfile
+17 -38
View File
@@ -12,7 +12,7 @@ Built with **pnpm workspace monorepo** with 3 services and 1 shared library:
|---------|------|-------------|
| `discord-moderation-backend` | `services/backend` | Express HTTP/WS server, REST API, Redis bridge |
| `@bete/discord-gateway` | `services/discord-gateway` | Discord client, voice recording, message capture, AI moderation |
| `frontend` | `services/frontend/frontend` | Leptos 0.7 CSR WASM dashboard |
| `frontend` | `services/frontend` | Next.js 16 (React 19) static dashboard, Tailwind v4, shadcn/ui |
| `@bete/shared` | `packages/shared` | Shared types, errors, logger, utilities |
**Database:** PostgreSQL (Drizzle ORM) — NOT SQLite.
@@ -28,7 +28,7 @@ Discord
|
v
discord-gateway ---- Redis ---- backend ---- WebSocket ---- frontend
| pub/sub (broadcast) (Leptos WASM)
| pub/sub (broadcast) (Next.js static)
| |
| |
<------------------+
@@ -194,43 +194,22 @@ The core service that connects to Discord using `discord.js-selfbot-v13`.
- `src/shared/database/voiceRecordingRepo.ts` — Voice recording queries
- `src/shared/discord/clientOptions.ts` — Discord client configuration
### frontend (`services/frontend/frontend`)
### frontend (`services/frontend`)
Leptos 0.7 CSR WASM + TypeScript + CSS dashboard.
Next.js 16 (React 19) static export dashboard, built with TypeScript + Tailwind v4 + shadcn/ui + base-ui.
**Tech stack:**
- Leptos 0.7 CSR (WASM via `#[wasm_bindgen(start)]`)
- Trunk for bundling
- Plain CSS with design tokens
- Canvas 2D via `web-sys` for audio visualization
- CSS animations (GSAP/Framer Motion dihapus — unused)
- `web-sys` primitives (WebSocket, AudioContext, IntersectionObserver)
- `lucide-leptos` 3 for icons
- Next.js 16 (App Router, static export)
- React 19 with React Compiler
- TypeScript strict
- Tailwind v4 + shadcn/ui + base-ui components
- lucide-react icons
**Feature structure (entity + feature slices):**
- `entities/` — Type exports re-exported from shared API client
- `guild/types.ts` — Guild, Channel
- `message/types.ts` — MessageRecord, PageResult
- `voice/types.ts` — ActiveSpeaker, VoiceStatus
- `media/types.ts` — MediaItem, MediaMode, MediaState
- `ui/types.ts` — UIState, DashboardTab
- `features/`
- `live/` — Voice connection, music player, screenshare, recordings
- Components: ActiveSpeakers, AudioVisualizer, MusicSubPanel, NowPlaying, RecordingsSubPanel, ScreenSubPanel, VoiceConnectionCard
- Hooks: `useVoiceControl`, `useMediaControl`
- `messages/` — Message list with filters
- Hooks: `useMessages`
- `shared/`
- `api/client.ts` — All HTTP API calls + types
- `ws/socket.ts` — WebSocket singleton with `useDashboardSocket` hook
- `ws/events.ts` — Typed event map
- `hooks/` — useAudioPlayback, useAudioTransmit, useUIState, useMascotChat, useMascotSummary, useFramerStagger, useGsapTransition, useLocalStorage
- `ui/` — Reusable UI components (Badge, Button, Card, Input, Select, Skeleton, Tabs, Toast, ScrollArea)
- `lib/utils.ts` — `cn()` and other utilities
**WebSocket protocol:**
- Binary: PCM audio data (24kHz mono s16le)
- JSON events: message_*, voice_*, attachment_*, user_state, ui_state, media_state, heartbeat
**Feature structure:**
- `src/app/` — App Router pages (login, dashboard with tabs)
- `src/features/` — Feature components (dashboard, messages, live, mascot)
- `src/lib/` — Shared utilities (types, API client, WebSocket, hooks)
- `src/components/` — Shared UI components (layout, ui)
### shared (`packages/shared`)
@@ -388,16 +367,16 @@ pnpm install
# Run each service in development mode (separate terminal each)
pnpm run dev:backend # Backend on port 3001
pnpm run dev:discord-gateway # Discord client + all features
pnpm run dev:web # Frontend via trunk serve
pnpm run dev:web # Frontend via next dev (port 3000)
# Build
pnpm run build:backend
pnpm run build:discord-gateway
pnpm run build:web # trunk build --release
pnpm run build:web # next build (static export)
# Type checking
pnpm run typecheck # Node services (pnpm -r)
pnpm run typecheck:web # Leptos frontend (cargo check)
pnpm run typecheck:web # Frontend typecheck (next build)
# Lint (Biome)
pnpm run lint
+5 -5
View File
@@ -9,7 +9,7 @@
#
# Usage:
# ./deploy.sh # build + deploy all services
# ./deploy.sh --frontend # frontend WASM only
# ./deploy.sh --frontend # frontend (Next.js) only
# ./deploy.sh --backend # backend JS only
# ./deploy.sh --gateway # discord-gateway JS only
# ./deploy.sh --all # same as no-flag (default)
@@ -32,7 +32,7 @@ APP_DIR="/opt/imphenbot"
COMPOSE_FILE="infra/docker/docker-compose.yml"
# Local build output directories (relative to repo root)
FRONTEND_DIST="services/frontend/frontend/dist"
FRONTEND_DIST="services/frontend/out"
BACKEND_DIST="services/backend/dist"
GATEWAY_DIST="services/discord-gateway/dist"
@@ -118,9 +118,9 @@ if $DO_BUILD; then
fi
if $DO_FRONTEND; then
log "Building frontend (WASM)..."
cd services/frontend/frontend
trunk build --release 2>&1 | tail -5 || die "Frontend build failed"
log "Building frontend (Next.js)..."
cd services/frontend
bun run build 2>&1 | tail -5 || die "Frontend build failed"
cd "$REPO_ROOT"
ok "Frontend built"
fi
+3 -3
View File
@@ -7,9 +7,9 @@
"scripts": {
"dev:backend": "pnpm --filter './services/backend' run dev",
"dev:discord-gateway": "pnpm --filter './services/discord-gateway' run dev",
"dev:web": "cd services/frontend/frontend && trunk serve --address 0.0.0.0",
"build:web": "cd services/frontend/frontend && trunk build --release",
"typecheck:web": "cd services/frontend/frontend && cargo check",
"dev:web": "pnpm --filter './services/frontend' run dev",
"build:web": "pnpm --filter './services/frontend' run build",
"typecheck:web": "cd services/frontend && bun run build",
"build:backend": "pnpm --filter './services/backend' run build",
"build:discord-gateway": "pnpm --filter './services/discord-gateway' run build",
"typecheck": "pnpm -r run typecheck",
-3
View File
@@ -1,3 +0,0 @@
# API/WS endpoints — set these before building
VITE_BE_API_URL=http://localhost:3001
VITE_BE_WS_URL=ws://localhost:3001/ws
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
-2557
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
[workspace]
resolver = "2"
members = ["shared-types", "frontend"]
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+53
View File
@@ -0,0 +1,53 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noUnknownAtRules": "off",
"useIterableCallbackReturn": "off",
"noArrayIndexKey": "warn"
},
"a11y": {
"useButtonType": "off",
"noAutofocus": "off"
},
"performance": {
"noImgElement": "warn"
},
"security": {
"noDangerouslySetInnerHtml": "warn"
},
"correctness": {
"noInvalidUseBeforeDeclaration": "off",
"noUnusedFunctionParameters": "warn"
}
},
"domains": {
"next": "recommended",
"react": "recommended"
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
File diff suppressed because one or more lines are too long
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
-63
View File
@@ -1,63 +0,0 @@
[package]
name = "frontend"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
leptos = { version = "=0.9.0-alpha", features = ["csr"] }
leptos-use = "0.19"
lucide-leptos = "3.23"
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 = { version = "0.3", features = ["futures"] }
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"
shared-types = { path = "../shared-types" }
-7
View File
@@ -1,7 +0,0 @@
[build]
target = "index.html"
dist = "dist"
[serve]
port = 8080
open = false
-20
View File
@@ -1,20 +0,0 @@
<!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="#3b82f6" />
<title>IMPHNEN -- Discord Moderation</title>
<link data-trunk rel="rust" data-crate="frontend" data-wasm="frontend.wasm" />
<link data-trunk rel="css" href="src/styles/bundle.css" />
<link data-trunk rel="copy-dir" href="public/" />
<!-- Inter + JetBrains Mono fonts -->
<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=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!-- Preload WASM -->
<link rel="preload" href="/frontend.wasm" as="fetch" crossorigin="anonymous" />
</head>
<body>
</body>
</html>
@@ -1 +0,0 @@
# Trunk copies this directory to dist/
@@ -1,24 +0,0 @@
use crate::api::client::{request, ApiError};
use serde::{Deserialize, Serialize};
use crate::{log_error, log_info, log_warn, make_logger};
make_logger!();
#[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)
}
@@ -1,184 +0,0 @@
use serde::de::DeserializeOwned;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Headers, Request, RequestInit, RequestMode, Response};
use crate::{log_debug, log_error, make_logger};
make_logger!();
#[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(|_| {
let msg = "Failed to create headers";
log_error!("API {} {} failed: {}", method, path, msg);
ApiError {
message: msg.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();
}
log_debug!("{} {} ->", method, path);
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| {
let msg = format!("Failed to create request: {:?}", e);
log_error!("API {} {} failed: {}", method, path, msg);
ApiError {
message: msg,
status_code: 0,
}
})?;
let window = web_sys::window().ok_or_else(|| {
let msg = "No window";
log_error!("API {} {} failed: {}", method, path, msg);
ApiError {
message: msg.to_string(),
status_code: 0,
}
})?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| {
let msg = format!("Fetch failed: {:?}", e);
log_error!("API {} {} failed: {}", method, path, msg);
ApiError {
message: msg,
status_code: 0,
}
})?;
let response: Response = resp_value.dyn_into().map_err(|_| {
let msg = "Invalid response";
log_error!("API {} {} failed: {}", method, path, msg);
ApiError {
message: msg.to_string(),
status_code: 0,
}
})?;
let status = response.status();
if status >= 400 {
let text = JsFuture::from(response.text().map_err(|_| {
let msg = "Failed to read error body";
log_error!("API {} {} failed: {}", method, path, msg);
ApiError {
message: msg.to_string(),
status_code: status,
}
})?)
.await
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default();
log_error!("API {} {} failed: status={} {}", method, path, status, text);
return Err(ApiError {
message: text,
status_code: status,
});
}
let text = JsFuture::from(response.text().map_err(|_| {
let msg = "Failed to read response body";
log_error!("API {} {} failed: {}", method, path, msg);
ApiError {
message: msg.to_string(),
status_code: status,
}
})?)
.await
.map_err(|_| {
let msg = "Failed to await response";
log_error!("API {} {} failed: {}", method, path, msg);
ApiError {
message: msg.to_string(),
status_code: status,
}
})?
.as_string()
.ok_or_else(|| {
let msg = "Response is not text";
log_error!("API {} {} failed: {}", method, path, msg);
ApiError {
message: msg.to_string(),
status_code: status,
}
})?;
log_debug!("{} {} <- {}", method, path, status);
serde_json::from_str(&text).map_err(|e| {
let msg = format!(
"JSON parse error: {} — body: {}",
e,
&text[..text.len().min(200)]
);
log_error!("API {} {} failed: {}", method, path, msg);
ApiError {
message: msg,
status_code: status,
}
})
}
pub async fn request_no_body(method: &str, path: &str) -> Result<(), ApiError> {
request::<serde_json::Value>(method, path, None).await?;
Ok(())
}
@@ -1,12 +0,0 @@
use crate::api::client::{request, ApiError};
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppConfigResponse {
pub monitor_guild_id: Option<String>,
}
/// GET /api/config
pub async fn get_config() -> Result<AppConfigResponse, ApiError> {
request("GET", "/api/config", None).await
}
@@ -1,96 +0,0 @@
use crate::api::client::{request, ApiError};
use shared_types::dashboard::*;
use crate::{log_debug, make_logger};
make_logger!();
/// GET /api/dashboard/stats
pub async fn get_dashboard_stats() -> Result<DashboardStats, ApiError> {
log_debug!("get_dashboard_stats");
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> {
log_debug!("get_dashboard_users: limit={:?}, cursor={:?}, search={:?}", limit, cursor, search);
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> {
log_debug!("get_dashboard_user_detail: user_id={}", user_id);
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> {
log_debug!("get_dashboard_channels: limit={:?}, cursor={:?}, search={:?}, guild_id={:?}", limit, cursor, search, guild_id);
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> {
log_debug!("get_dashboard_channel_detail: channel_id={}", channel_id);
request(
"GET",
&format!("/api/dashboard/channels/{}", channel_id),
None,
)
.await
}
@@ -1,32 +0,0 @@
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,
}
#[derive(Debug, Deserialize)]
pub struct ChatHistoryMessage {
pub role: String,
pub content: 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
}
/// GET /api/mascot/chat/history
pub async fn get_chat_history() -> Result<Vec<ChatHistoryMessage>, ApiError> {
request("GET", "/api/mascot/chat/history", None).await
}
@@ -1,109 +0,0 @@
use crate::api::client::{request, ApiError};
use shared_types::message::{MessageRecord, PageResult};
use crate::{log_debug, make_logger};
make_logger!();
/// 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> {
log_debug!("get_messages: guild_id={}, limit={:?}, channel_id={:?}, cursor={:?}", guild_id, limit, channel_id, cursor);
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
/// Backend `GET /review` accepts `channelId` and `limit` (not guildId).
pub async fn get_review_messages(
limit: Option<u32>,
channel_id: Option<&str>,
) -> Result<PageResult<MessageRecord>, ApiError> {
log_debug!("get_review_messages: limit={:?}, channel_id={:?}", limit, channel_id);
let mut path = "/api/review".to_string();
let mut params = vec![];
if let Some(l) = limit {
params.push(format!("limit={}", l));
}
if let Some(c) = channel_id {
params.push(format!("channelId={}", c));
}
if !params.is_empty() {
path.push_str(&format!("?{}", params.join("&")));
}
request("GET", &path, None).await
}
/// GET /api/messages/images?guildId=&limit=
pub async fn get_images(
guild_id: &str,
limit: Option<u32>,
) -> Result<PageResult<MessageRecord>, ApiError> {
log_debug!("get_images: guild_id={}, limit={:?}", guild_id, limit);
let mut path = format!("/api/messages/images?guildId={}", guild_id);
if let Some(l) = limit {
path.push_str(&format!("&limit={}", l));
}
request("GET", &path, None).await
}
/// GET /api/messages/detail/{id}
pub async fn get_message_detail(id: &str) -> Result<Option<MessageRecord>, ApiError> {
log_debug!("get_message_detail: id={}", id);
request("GET", &format!("/api/messages/detail/{}", id), None).await
}
/// POST /api/messages/{id}/reanalyze
pub async fn reanalyze_message(id: &str) -> Result<(), ApiError> {
log_debug!("reanalyze_message: id={}", id);
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> {
log_debug!("reanalyze_batch");
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct BatchResp {
ok: bool,
count: u64,
}
let resp: BatchResp = request("POST", "/api/messages/reanalyze-batch", Some("{}")).await?;
Ok(resp.count)
}
/// GET /api/analysis/search?q=&limit=
pub async fn search_messages(
query: &str,
limit: Option<u32>,
) -> Result<Vec<MessageRecord>, ApiError> {
log_debug!("search_messages: query={}, limit={:?}", query, limit);
#[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)
}
@@ -1,8 +0,0 @@
pub mod auth;
pub mod client;
pub mod config;
pub mod dashboard;
pub mod mascot;
pub mod messages;
pub mod recordings;
pub mod voice;
@@ -1,25 +0,0 @@
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
}
@@ -1,90 +0,0 @@
use crate::api::client::{request, ApiError};
use serde::Serialize;
use shared_types::guild::{Channel, Guild};
use shared_types::media::MediaState;
use shared_types::voice::VoiceStatus;
/// GET /api/guilds
pub async fn get_guilds() -> Result<Vec<Guild>, ApiError> {
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
}
-130
View File
@@ -1,130 +0,0 @@
use crate::api::config as config_api;
use crate::features::dashboard::DashboardPanel;
use crate::features::live::LivePanel;
use crate::features::messages::MessagesPanel;
use crate::features::polish::components::{MascotChatbot, ParticleBackground};
use crate::features::polish::{initial_theme, ThemeContext};
use crate::layout::sidebar::Sidebar;
use crate::ws::context::WsContext;
use leptos::prelude::*;
use shared_types::ui_state::Tab;
use wasm_bindgen_futures::spawn_local;
use crate::{log_info, log_warn, make_logger};
make_logger!();
fn get_ws_url() -> String {
web_sys::window()
.map(|w| {
let loc = w.location();
let protocol = loc.protocol().unwrap_or_else(|_| "http:".to_string());
let host = loc.host().unwrap_or_else(|_| "localhost:3001".to_string());
let ws_proto = if protocol.starts_with("https") {
"wss"
} else {
"ws"
};
format!("{}://{}/ws", ws_proto, host)
})
.unwrap_or_else(|| "ws://localhost:3001/ws".to_string())
}
#[derive(Clone)]
pub struct AppConfig {
pub monitor_guild_id: RwSignal<Option<String>>,
}
#[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>>,
}
#[component]
pub fn App() -> impl IntoView {
let auth = AuthContext {
authenticated: RwSignal::new(false),
password: RwSignal::new(String::new()),
};
let ui = UiContext {
active_tab: RwSignal::new(Tab::Messages),
selected_guild: RwSignal::new(None),
};
let theme = ThemeContext {
theme: RwSignal::new(initial_theme()),
};
provide_context(auth.clone());
provide_context(ui.clone());
provide_context(theme.clone());
let config = AppConfig {
monitor_guild_id: RwSignal::new(None),
};
provide_context(config.clone());
let ws = WsContext::new(&get_ws_url());
provide_context(ws.clone());
ws.connect();
log_info!("App mounted, WS connecting to {}", get_ws_url());
spawn_local({
let config = config.clone();
async move {
match config_api::get_config().await {
Ok(cfg) => {
log_info!("[config] fetched OK — monitorGuildId={:?}", cfg.monitor_guild_id);
config.monitor_guild_id.set(cfg.monitor_guild_id);
}
Err(e) => {
log_warn!("[config] failed to fetch: {}", e);
}
}
}
});
Effect::new(move |_| {
if auth.authenticated.get() {
spawn_local({
let config = config.clone();
async move {
match config_api::get_config().await {
Ok(cfg) => {
config.monitor_guild_id.set(cfg.monitor_guild_id);
}
Err(e) => {
log_info!("[config] fetch after auth failed: {}", e);
}
}
}
});
}
});
view! {
<div data-theme=move || theme.theme.get()>
<ParticleBackground />
<div class="app-shell">
<Sidebar />
<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>
</div>
{move || auth.authenticated.get().then(|| view! { <MascotChatbot /> })}
</div>
}
}
-113
View File
@@ -1,113 +0,0 @@
use crate::api::auth as auth_api;
use crate::app::AuthContext;
use crate::app::UiContext;
use leptos::prelude::*;
use shared_types::ui_state::Tab;
use wasm_bindgen_futures::spawn_local;
use crate::{log_error, log_info, log_warn, make_logger};
make_logger!();
#[component]
pub fn AuthOverlay() -> impl IntoView {
let auth = use_context::<AuthContext>().expect("AuthContext not provided");
let ui = use_context::<UiContext>();
let (password, set_password) = signal(String::new());
let (error, set_error) = signal(Option::<String>::None);
let (loading, set_loading) = signal(false);
let handle_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
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;
let set_error_clone = set_error;
spawn_local(async move {
match auth_api::login(&pwd_clone).await {
Ok(true) => {
log_info!("Auth login successful");
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) => {
log_warn!("Auth login failed - wrong password");
set_error_clone.set(Some("Login gagal — password salah".to_string()));
}
Err(e) => {
log_error!("Auth login error: {}", e.message);
set_error_clone.set(Some(format!("Error: {}", e.message)));
}
}
set_loading_clone.set(false);
});
};
let tab_messages = ui.as_ref().map(|u| u.active_tab);
let skip_dismiss = move |_| {
if let Some(ref t) = tab_messages {
t.set(Tab::Messages);
}
};
view! {
<div class="modal-overlay" on:click=skip_dismiss>
<div class="modal-content auth-box" on:click=move |ev| ev.stop_propagation()>
<div class="modal-body" style="position:relative">
<button
class="auth-close-btn"
on:click=skip_dismiss
title="Tutup"
>"×"</button>
<div class="auth-lock">
<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 class="auth-title">"Akses Dashboard"</h2>
<p class="auth-desc">"Masukkan password admin untuk melanjutkan"</p>
<form class="auth-form" on:submit=handle_submit>
<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 class="auth-error">{e}</p>
})}
<button
type="submit"
class="btn btn-primary btn-lg"
disabled=move || loading.get()
>
{move || if loading.get() { "Memproses..." } else { "Masuk" }}
</button>
<button
on:click=skip_dismiss
class="btn btn-ghost btn-sm"
>
"Lihat dashboard saja"
</button>
</form>
</div>
</div>
</div>
}
}
@@ -1,143 +0,0 @@
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()
}
@@ -1,7 +0,0 @@
pub mod channel_summary_list;
pub mod stats_overview;
pub mod user_summary_list;
pub use channel_summary_list::ChannelSummaryList;
pub use stats_overview::StatsOverview;
pub use user_summary_list::UserSummaryList;
@@ -1,150 +0,0 @@
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()
}
@@ -1,148 +0,0 @@
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()
}
@@ -1,276 +0,0 @@
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;
use crate::{log_error, log_info, make_logger};
make_logger!();
#[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);
log_info!("Dashboard fetching stats...");
spawn_local(async move {
match crate::api::dashboard::get_dashboard_stats().await {
Ok(data) => {
log_info!("Dashboard stats loaded: {} messages", data.total_messages);
stats.set(Some(data));
}
Err(err) => {
log_error!("Dashboard stats error: {}", 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);
log_info!("Dashboard fetching users...");
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) => {
log_info!("Dashboard users loaded: {} users", page.data.len());
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) => {
log_error!("Dashboard users error: {}", 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);
log_info!("Dashboard fetching channels...");
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) => {
log_info!("Dashboard channels loaded: {} channels", page.data.len());
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) => {
log_error!("Dashboard channels error: {}", err);
channels_error.set(Some(format!("Failed to load channels: {}", err)));
}
}
channels_loading.set(false);
});
});
// Initial fetch on mount (use spawn_local to avoid reactive dependency tracking)
{
let fetch_stats = fetch_stats.clone();
let fetch_users = fetch_users.clone();
let fetch_channels = fetch_channels.clone();
spawn_local(async 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" }>
{move || {
let on_retry = {
let fetch_stats = fetch_stats.clone();
Box::new(move || fetch_stats())
};
view! {
<StatsOverview
stats=stats.get()
loading=stats_loading.get()
error=stats_error.get()
on_retry=on_retry
/>
}
}}
</div>
<div class="tab-content" style:display=move || if active_tab.get() == DashboardTab::Users { "block" } else { "none" }>
{move || {
let on_search_change = {
let fetch_users = fetch_users.clone();
Box::new(move |value| {
users_search.set(value);
users_cursor.set(None);
fetch_users(true);
})
};
let on_load_more = {
let fetch_users = fetch_users.clone();
Box::new(move || fetch_users(false))
};
let on_retry = {
let fetch_users = fetch_users.clone();
Box::new(move || fetch_users(true))
};
view! {
<UserSummaryList
users=users.get()
loading=users_loading.get()
error=users_error.get()
search=users_search.get()
has_more=users_cursor.get().is_some()
on_search_change=on_search_change
on_load_more=on_load_more
on_retry=on_retry
/>
}
}}
</div>
<div class="tab-content" style:display=move || if active_tab.get() == DashboardTab::Channels { "block" } else { "none" }>
{move || {
let on_search_change = {
let fetch_channels = fetch_channels.clone();
Box::new(move |value| {
channels_search.set(value);
channels_cursor.set(None);
fetch_channels(true);
})
};
let on_load_more = {
let fetch_channels = fetch_channels.clone();
Box::new(move || fetch_channels(false))
};
let on_retry = {
let fetch_channels = fetch_channels.clone();
Box::new(move || fetch_channels(true))
};
view! {
<ChannelSummaryList
channels=channels.get()
loading=channels_loading.get()
error=channels_error.get()
search=channels_search.get()
has_more=channels_cursor.get().is_some()
on_search_change=on_search_change
on_load_more=on_load_more
on_retry=on_retry
/>
}
}}
</div>
</div>
</div>
}
}
#[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>
}
}
@@ -1,2 +0,0 @@
pub mod pcm_decoder;
pub mod ring_buffer;
@@ -1,70 +0,0 @@
use wasm_bindgen::prelude::*;
/// 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.clamp(-1.0, 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 via wasm-bindgen
fn encode_bytes_base64(data: &[u8]) -> String {
// Convert bytes 0-255 to a Latin-1 string (each byte → char with same codepoint)
let latin1: String = data.iter().map(|&b| b as char).collect();
js_btoa(&latin1)
}
/// Direct wasm-bindgen binding to the browser's btoa function
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = btoa)]
fn js_btoa(input: &str) -> String;
}
@@ -1,124 +0,0 @@
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(),
}
}
}
@@ -1,80 +0,0 @@
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="speak-list">
<For
each=move || speakers.get()
key=|s| s.user_id.clone() + &s.username
let:speaker
>
<div style="display:flex;align-items:center;gap:0.75rem;border-radius:0.75rem;border:1px solid var(--surface-border);background:var(--surface-base);padding:0.75rem">
<div style="width:2rem;height:2rem;flex-shrink:0">
{speaker.avatar.as_ref().map(|avatar_url| {
let url = avatar_url.clone();
view! {
<img
src=url
alt=""
style="width:2rem;height:2rem;border-radius:9999px;object-fit:cover;box-shadow:0 0 0 2px rgba(35,161,235,0.3)"
/>
}
})}
</div>
<div style="min-width:0;flex:1">
<div class="truncate text-sm font-medium">
{speaker.username.clone()}
</div>
<div style="display:flex;align-items:center;gap:0.375rem">
<span style=move || {
if speaker.speaking {
"display:inline-block;width:0.5rem;height:0.5rem;border-radius:9999px;background:#10b981"
} else {
"display:inline-block;width:0.5rem;height:0.5rem;border-radius:9999px;background:color-mix(in srgb, var(--text-tertiary) 40%, transparent)"
}
}></span>
<span style=move || {
if speaker.speaking {
"font-size:0.75rem;font-weight:500;color:#059669"
} else {
"font-size:0.75rem;font-weight:500;color:var(--text-secondary)"
}
}>
{move || if speaker.speaking { "Speaking" } else { "Silent" }}
</span>
</div>
</div>
</div>
</For>
</div>
}
}
>
<div style="border-radius:0.75rem;border:1px solid var(--surface-border);background:var(--surface-base);padding:2rem;text-align:center">
<div>
<div style="font-size:2.25rem;line-height:2.5rem">
"🎤"
</div>
<p style="font-size:0.875rem;color:var(--text-secondary)">
"No active speakers"
</p>
</div>
</div>
</Show>
</div>
}
}
@@ -1,79 +0,0 @@
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 = RwSignal::new(vec![0.0; 32]);
let (tick, set_tick) = signal(0u32);
// Drive periodic updates: increment tick every 100ms
wasm_bindgen_futures::spawn_local(async move {
loop {
gloo_timers::future::TimeoutFuture::new(100).await;
set_tick.update(|t| *t = t.wrapping_add(1));
}
});
// Effect reacts to tick changes, updating bars from PCM data each frame
Effect::new(move |_| {
tick.get(); // Track — Effect re-runs on each tick (every 100ms)
if let Some(ref pcm_arc) = pcm_data {
if let Ok(pcm_vec) = pcm_arc.lock() {
let computed = compute_frequency_bands(&pcm_vec);
bars.update(|b| {
for (i, band) in b.iter_mut().enumerate() {
let target = computed.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0);
*band = *band * 0.7 + target * 0.3; // Smooth decay
}
});
}
}
});
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
}
@@ -1,94 +0,0 @@
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 = RwSignal::new(0.0f32);
let peak = RwSignal::new(0.0f32);
let (tick, set_tick) = signal(0u32);
// Drive periodic updates: increment tick every 100ms
wasm_bindgen_futures::spawn_local(async move {
loop {
gloo_timers::future::TimeoutFuture::new(100).await;
set_tick.update(|t| *t = t.wrapping_add(1));
}
});
// Effect reacts to tick changes, updating level from PCM data each frame
Effect::new(move |_| {
tick.get(); // Track — Effect re-runs on each tick
if !active {
return;
}
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 style="font-size:0.75rem;font-weight:500;color:var(--text-secondary);display:block;margin-bottom:var(--space-1)">{l}</label>
})}
<div class="mic-row">
{/* Main level bar */}
<div class="mic-track">
<div
class=move || format!("h-full {} transition-all", level_color())
style=move || format!("width: {}%", level_percent())
></div>
{/* Peak indicator */}
<div
class="mic-clip"
style=move || format!("left: {}%", peak_percent())
></div>
</div>
{/* Percentage display */}
<span style="font-size:0.75rem;font-family:monospace;width:2rem;text-align: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()
}
@@ -1,19 +0,0 @@
pub mod active_speakers;
pub mod audio_visualizer;
pub mod mic_level_meter;
pub mod music_sub_panel;
pub mod now_playing;
pub mod recordings_sub_panel;
pub mod screen_sub_panel;
pub mod voice_connection_card;
pub mod waveform_player;
pub use active_speakers::ActiveSpeakers;
pub use audio_visualizer::AudioVisualizer;
pub use mic_level_meter::MicLevelMeter;
pub use music_sub_panel::MusicSubPanel;
pub use now_playing::NowPlaying;
pub use recordings_sub_panel::RecordingsSubPanel;
pub use screen_sub_panel::ScreenSubPanel;
pub use voice_connection_card::VoiceConnectionCard;
pub use waveform_player::WaveformPlayer;
@@ -1,53 +0,0 @@
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) = signal::<String>(String::new());
let handle_queue_click = move |_| {
let url = url_input.get_untracked().trim().to_string();
if !url.is_empty() {
if let Some(ref cb) = on_queue {
cb(url.clone());
set_url_input.set(String::new());
}
}
};
view! {
<div class="card">
<div class="card-header">
<div class="card-title" style="display:flex;align-items:center;gap:var(--space-2)">
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9 8h6v8h-6z"></path>
</svg>
"Music"
</div>
</div>
<div class="card-content music-body">
<div class="music-body">
<label style="font-size:0.75rem;font-weight:500;color:var(--text-secondary)">"YouTube URL or Search"</label>
<input
type="text"
class="input"
placeholder="youtube.com/watch?v=... or song name"
prop:value=url_input
on:input=move |ev| set_url_input.set(event_target_value(&ev))
/>
</div>
<button
class="btn btn-primary"
style="width:100%"
on:click=handle_queue_click
disabled=move || url_input.get().is_empty()
>
"Queue Music"
</button>
</div>
</div>
}
}
@@ -1,106 +0,0 @@
use leptos::prelude::*;
use shared_types::media::MediaState;
/// NowPlaying — Displays current media item and queue info
/// Accepts an optional RwSignal to enable real-time updates from WebSocket events.
#[component]
pub fn NowPlaying(
#[prop(optional)] media_rw: Option<RwSignal<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 {
// Use provided signal, or fall back to a local one for static usage
let media_state = media_rw.unwrap_or_else(|| RwSignal::new(None));
// 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="card">
<div class="card-header">
<div class="card-title">"Now Playing"</div>
</div>
<div class="card-content np-body">
{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="np-body">
<div class="text-sm font-medium text-foreground truncate">
{title}
</div>
<div class="np-meta">
<span>{format!("{}s", duration_sec)}</span>
</div>
<div class="np-tags">
{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>
}
}
@@ -1,181 +0,0 @@
use crate::api::recordings::{delete_recording, get_recordings};
use leptos::prelude::*;
use shared_types::recording::VoiceRecording;
/// RecordingsSubPanel — Paginated list of voice recordings
/// Accepts an optional refresh_trigger signal to reload when a new recording is uploaded.
#[component]
pub fn RecordingsSubPanel(
#[prop(optional)] refresh_trigger: Option<ReadSignal<u64>>,
) -> impl IntoView {
let recordings = RwSignal::new(Vec::<VoiceRecording>::new());
let loading = RwSignal::new(false);
let has_more = RwSignal::new(true);
let next_cursor = RwSignal::new(None::<String>);
// Load recordings
let load = move |reset: bool| {
if loading.get_untracked() {
return;
}
loading.set(true);
let cursor_val = if reset {
None
} else {
next_cursor.get_untracked()
};
wasm_bindgen_futures::spawn_local({
async move {
match get_recordings(Some(20), cursor_val.as_deref()).await {
Ok(resp) => {
if reset {
recordings.set(resp.items);
} else {
let mut current = recordings.get_untracked();
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, and reload when refresh_trigger changes (e.g., new recording uploaded)
Effect::new(move |_| {
if let Some(trigger) = refresh_trigger {
trigger.get(); // Track — re-run when WS signals a new recording
}
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 width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<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="rec-empty">
<p style="font-size:0.875rem;color:var(--text-secondary)">"No recordings yet."</p>
<p style="font-size:0.75rem;color:var(--text-secondary)">"Join a voice channel to start recording."</p>
</div>
}.into_any()
} else {
view! {
<div class="rec-list">
{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="rec-item">
<div class="rec-info">
<div class="rec-name">{username}</div>
<div class="rec-meta">
<span>{channel_name}</span>
<span>"·"</span>
<span>{format_size(rec.size_bytes)}</span>
<span>"·"</span>
<span>{created_at}</span>
</div>
</div>
<div class="rec-actions">
{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="rec-footer">
<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()
}
@@ -1,83 +0,0 @@
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) = signal::<bool>(false);
let has_start = on_start_stream.is_some();
let has_stop = on_stop_stream.is_some();
view! {
<div class="card">
<div class="card-header">
<div class="card-title" style="display:flex;align-items:center;gap:var(--space-2)">
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
"Screenshare"
</div>
</div>
<div class="card-content" style="display:flex;flex-direction:column;gap:var(--space-3)">
<p style="font-size:0.75rem;color:var(--text-secondary)">
"Stream your screen to the voice channel for everyone to see."
</p>
<div class="scrn-actions">
{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_untracked() {
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_untracked() {
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="scrn-status">
"🔴 Live streaming..."
</div>
}
})
}}
</div>
</div>
}
}
@@ -1,220 +0,0 @@
use crate::features::live::hooks::use_voice_control::{use_voice_control, VoiceControlState};
use leptos::prelude::*;
use wasm_bindgen::JsCast;
/// VoiceConnectionCard component for Leptos
/// Renders guild and voice channel selectors with connect/disconnect controls
#[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) = signal::<String>(String::new());
// Reactive signal for selected channel
let (selected_channel, set_selected_channel) = signal::<String>(String::new());
// When guild is selected, load voice channels
Effect::new(move |_| {
let guild_id = selected_guild.get();
if !guild_id.is_empty() {
(state.load_voice_channels)(guild_id);
}
});
// Load guilds on mount
Effect::new(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 {
let _: () = view! { <></> };
().into_any()
}
}}
</div>
</div>
</div>
}
}
@@ -1,103 +0,0 @@
use leptos::prelude::*;
use wasm_bindgen::JsCast;
/// 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 = RwSignal::new(false);
let current_time = RwSignal::new(0.0);
let duration = RwSignal::new(0.0);
let audio_id = format!("audio_{}", audio_url);
// Clone audio_url for the audio element
let audio_src = audio_url.clone();
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_untracked() {
let _ = audio.pause();
is_playing.set(false);
} else {
if audio.ended() {
audio.set_current_time(0.0);
}
if audio.play().is_ok() {
is_playing.set(true);
}
}
}
}
};
let _ = audio_url; // Mark as used for the audio_id
view! {
<div class="wave-wrap">
<audio
id=audio_id.clone()
preload="auto"
src=audio_src
style="display:none"
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="wave-progress">
<div
class="wave-bar"
style=move || format!("width: {}%", progress_pct(current_time.get(), duration.get()))
></div>
</div>
<div class="wave-info">
<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="wave-time">
<span>{move || format_time(current_time.get())}</span>
<span class="wave-title">{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)
}
@@ -1,4 +0,0 @@
pub mod use_audio_playback;
pub mod use_audio_transmit;
pub mod use_media_control;
pub mod use_voice_control;
@@ -1,117 +0,0 @@
use crate::features::live::audio::pcm_decoder::decode_pcm_frame;
use crate::features::live::audio::ring_buffer::SharedRingBuffer;
use leptos::prelude::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
/// AudioPlaybackState — Manages PCM audio playback from WebSocket binary frames
#[derive(Clone)]
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>,
/// Abort flag to stop the playback loop (prevents leak on teardown)
pub abort: Arc<AtomicBool>,
}
/// 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 = RwSignal::new(false);
let volume = RwSignal::new(0.5);
AudioPlaybackState {
buffer,
active,
volume,
abort: Arc::new(AtomicBool::new(false)),
}
}
/// 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
/// The loop respects the abort flag in `AudioPlaybackState` for clean teardown.
pub fn start_playback(state: &AudioPlaybackState) {
if state.active.get_untracked() {
return;
}
state.active.set(true);
// Reset abort flag for a fresh start
state.abort.store(false, Ordering::Relaxed);
let buffer = state.buffer.clone();
let active = state.active;
let abort = state.abort.clone();
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_untracked() && !abort.load(Ordering::Relaxed) {
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.abort.store(true, Ordering::Relaxed);
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());
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();
}
}
@@ -1,79 +0,0 @@
use leptos::prelude::*;
use wasm_bindgen::{JsCast, JsValue};
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 = RwSignal::new(false);
let stream = StoredValue::new(None::<MediaStream>);
AudioTransmitState { active, stream }
}
/// Start microphone capture - requests getUserMedia and stores the stream
pub fn start_transmit(state: &AudioTransmitState) {
if state.active.get_untracked() {
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();
}
}
}
});
}

Some files were not shown because too many files have changed in this diff Show More