feat: migrate Leptos frontend to Next.js 16 (React 19)
Deploy to VPS / deploy (push) Failing after 42s
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:
@@ -47,14 +47,6 @@ jobs:
|
|||||||
version: 11.1.3
|
version: 11.1.3
|
||||||
run_install: false
|
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
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| `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 |
|
| `@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 |
|
| `@bete/shared` | `packages/shared` | Shared types, errors, logger, utilities |
|
||||||
|
|
||||||
**Database:** PostgreSQL (Drizzle ORM) — NOT SQLite.
|
**Database:** PostgreSQL (Drizzle ORM) — NOT SQLite.
|
||||||
@@ -28,7 +28,7 @@ Discord
|
|||||||
|
|
|
|
||||||
v
|
v
|
||||||
discord-gateway ---- Redis ---- backend ---- WebSocket ---- frontend
|
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/database/voiceRecordingRepo.ts` — Voice recording queries
|
||||||
- `src/shared/discord/clientOptions.ts` — Discord client configuration
|
- `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:**
|
**Tech stack:**
|
||||||
- Leptos 0.7 CSR (WASM via `#[wasm_bindgen(start)]`)
|
- Next.js 16 (App Router, static export)
|
||||||
- Trunk for bundling
|
- React 19 with React Compiler
|
||||||
- Plain CSS with design tokens
|
- TypeScript strict
|
||||||
- Canvas 2D via `web-sys` for audio visualization
|
- Tailwind v4 + shadcn/ui + base-ui components
|
||||||
- CSS animations (GSAP/Framer Motion dihapus — unused)
|
- lucide-react icons
|
||||||
- `web-sys` primitives (WebSocket, AudioContext, IntersectionObserver)
|
|
||||||
- `lucide-leptos` 3 for icons
|
|
||||||
|
|
||||||
**Feature structure (entity + feature slices):**
|
**Feature structure:**
|
||||||
- `entities/` — Type exports re-exported from shared API client
|
- `src/app/` — App Router pages (login, dashboard with tabs)
|
||||||
- `guild/types.ts` — Guild, Channel
|
- `src/features/` — Feature components (dashboard, messages, live, mascot)
|
||||||
- `message/types.ts` — MessageRecord, PageResult
|
- `src/lib/` — Shared utilities (types, API client, WebSocket, hooks)
|
||||||
- `voice/types.ts` — ActiveSpeaker, VoiceStatus
|
- `src/components/` — Shared UI components (layout, ui)
|
||||||
- `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
|
|
||||||
|
|
||||||
### shared (`packages/shared`)
|
### shared (`packages/shared`)
|
||||||
|
|
||||||
@@ -388,16 +367,16 @@ pnpm install
|
|||||||
# Run each service in development mode (separate terminal each)
|
# Run each service in development mode (separate terminal each)
|
||||||
pnpm run dev:backend # Backend on port 3001
|
pnpm run dev:backend # Backend on port 3001
|
||||||
pnpm run dev:discord-gateway # Discord client + all features
|
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
|
# Build
|
||||||
pnpm run build:backend
|
pnpm run build:backend
|
||||||
pnpm run build:discord-gateway
|
pnpm run build:discord-gateway
|
||||||
pnpm run build:web # trunk build --release
|
pnpm run build:web # next build (static export)
|
||||||
|
|
||||||
# Type checking
|
# Type checking
|
||||||
pnpm run typecheck # Node services (pnpm -r)
|
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)
|
# Lint (Biome)
|
||||||
pnpm run lint
|
pnpm run lint
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./deploy.sh # build + deploy all services
|
# ./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 --backend # backend JS only
|
||||||
# ./deploy.sh --gateway # discord-gateway JS only
|
# ./deploy.sh --gateway # discord-gateway JS only
|
||||||
# ./deploy.sh --all # same as no-flag (default)
|
# ./deploy.sh --all # same as no-flag (default)
|
||||||
@@ -32,7 +32,7 @@ APP_DIR="/opt/imphenbot"
|
|||||||
COMPOSE_FILE="infra/docker/docker-compose.yml"
|
COMPOSE_FILE="infra/docker/docker-compose.yml"
|
||||||
|
|
||||||
# Local build output directories (relative to repo root)
|
# Local build output directories (relative to repo root)
|
||||||
FRONTEND_DIST="services/frontend/frontend/dist"
|
FRONTEND_DIST="services/frontend/out"
|
||||||
BACKEND_DIST="services/backend/dist"
|
BACKEND_DIST="services/backend/dist"
|
||||||
GATEWAY_DIST="services/discord-gateway/dist"
|
GATEWAY_DIST="services/discord-gateway/dist"
|
||||||
|
|
||||||
@@ -118,9 +118,9 @@ if $DO_BUILD; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if $DO_FRONTEND; then
|
if $DO_FRONTEND; then
|
||||||
log "Building frontend (WASM)..."
|
log "Building frontend (Next.js)..."
|
||||||
cd services/frontend/frontend
|
cd services/frontend
|
||||||
trunk build --release 2>&1 | tail -5 || die "Frontend build failed"
|
bun run build 2>&1 | tail -5 || die "Frontend build failed"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
ok "Frontend built"
|
ok "Frontend built"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+3
-3
@@ -7,9 +7,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:backend": "pnpm --filter './services/backend' run dev",
|
"dev:backend": "pnpm --filter './services/backend' run dev",
|
||||||
"dev:discord-gateway": "pnpm --filter './services/discord-gateway' 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",
|
"dev:web": "pnpm --filter './services/frontend' run dev",
|
||||||
"build:web": "cd services/frontend/frontend && trunk build --release",
|
"build:web": "pnpm --filter './services/frontend' run build",
|
||||||
"typecheck:web": "cd services/frontend/frontend && cargo check",
|
"typecheck:web": "cd services/frontend && bun run build",
|
||||||
"build:backend": "pnpm --filter './services/backend' run build",
|
"build:backend": "pnpm --filter './services/backend' run build",
|
||||||
"build:discord-gateway": "pnpm --filter './services/discord-gateway' run build",
|
"build:discord-gateway": "pnpm --filter './services/discord-gateway' run build",
|
||||||
"typecheck": "pnpm -r run typecheck",
|
"typecheck": "pnpm -r run typecheck",
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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 -->
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
Generated
-2557
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
|||||||
[workspace]
|
|
||||||
resolver = "2"
|
|
||||||
members = ["shared-types", "frontend"]
|
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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" }
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
[build]
|
|
||||||
target = "index.html"
|
|
||||||
dist = "dist"
|
|
||||||
|
|
||||||
[serve]
|
|
||||||
port = 8080
|
|
||||||
open = false
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
Reference in New Issue
Block a user