Compare commits
30
Commits
2744e7035b
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a2d9abc6 | ||
|
|
d33942cc23 | ||
|
|
aa8d5dfd41 | ||
|
|
8584030633 | ||
|
|
f35710db3f | ||
|
|
0c7930bd01 | ||
|
|
e22e620bae | ||
|
|
eda32720c8 | ||
|
|
c0f66c78a3 | ||
|
|
b8a6f40b1b | ||
|
|
4931e6d1ca | ||
|
|
a3e6c4695a | ||
|
|
6de5342703 | ||
|
|
5a926dbd17 | ||
|
|
7985efbef6 | ||
|
|
71889ab689 | ||
|
|
518577d79d | ||
|
|
d04093ec6e | ||
|
|
05feb697f0 | ||
|
|
a5b5ccf5b0 | ||
|
|
99ec528a03 | ||
|
|
7dedac2094 | ||
|
|
9b211f05cf | ||
|
|
4825dc6d4d | ||
|
|
9ad7d16a17 | ||
|
|
62d131cf14 | ||
|
|
82025a19b2 | ||
|
|
3c7d722973 | ||
|
|
70931576dc | ||
|
|
8e1f5adaa4 |
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.test
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
recordings
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
test_out.nut
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
DISCORD_TOKEN=test_token_for_testing
|
|
||||||
RECORDINGS_DIR=./recordings
|
|
||||||
RECORDING_SEGMENT_MS=5000
|
|
||||||
VERBOSE=false
|
|
||||||
DECODER_ROTATE_MS=5000
|
|
||||||
DECODER_COOLDOWN_MS=30000
|
|
||||||
AUDIO_STREAM_SILENCE_DURATION_MS=3000
|
|
||||||
PACKET_FILTER_MIN_SIZE=8
|
|
||||||
OPUS_FRAME_SIZE=960
|
|
||||||
AUDIO_SAMPLE_RATE=48000
|
|
||||||
AUDIO_CHANNELS=2
|
|
||||||
AVATAR_SIZE=64
|
|
||||||
WEBSERVER_PORT=3000
|
|
||||||
VOICE_CONNECTION_TIMEOUT_MS=15000
|
|
||||||
RECONNECT_TIMEOUT_MS=5000
|
|
||||||
LOG_LEVEL=info
|
|
||||||
NODE_ENV=test
|
|
||||||
MONITOR_GUILD_ID=test_guild_id
|
|
||||||
PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload
|
|
||||||
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
|
|
||||||
ATTACHMENT_MAX_SIZE_MB=100
|
|
||||||
ATTACHMENT_RETRY_ATTEMPTS=3
|
|
||||||
BACKLOG_SYNC_HOURS=24
|
|
||||||
BACKLOG_SYNC_BATCH_SIZE=100
|
|
||||||
AI_ANALYSIS_ENABLED=false
|
|
||||||
AI_LLM_API_KEY=test_key
|
|
||||||
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
|
|
||||||
AI_LLM_MODEL=free
|
|
||||||
AI_ANALYSIS_TIMEOUT_MS=30000
|
|
||||||
DATABASE_TYPE=sqlite
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Mirror to Gitea
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Mirror to Gitea
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git remote add gitea "https://oauth2:${GITEA_TOKEN}@git.imrnes.team/MythEclipse/dc-recorder.git"
|
||||||
|
git push --mirror gitea
|
||||||
|
echo "✅ Mirrored to Gitea (MythEclipse/dc-recorder)"
|
||||||
@@ -5,3 +5,4 @@ dist/
|
|||||||
public/app/
|
public/app/
|
||||||
.muxer-queue.**
|
.muxer-queue.**
|
||||||
.claude/
|
.claude/
|
||||||
|
.env.test
|
||||||
+1
-3
@@ -1,6 +1,4 @@
|
|||||||
[submodule "vendor/discord.js-selfbot-v13"]
|
[submodule "vendor/discord.js-selfbot-v13"]
|
||||||
path = vendor/discord.js-selfbot-v13
|
path = vendor/discord.js-selfbot-v13
|
||||||
url = ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
|
url = ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
|
||||||
[submodule "vendor/Discord-video-stream"]
|
|
||||||
path = vendor/Discord-video-stream
|
|
||||||
url = ssh://git@43.134.105.109:22222/exceed/Discord-video-stream.git
|
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
|
# Install dependencies required by node-canvas, ffmpeg, and yt-dlp
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
python3 \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
make \
|
||||||
|
g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install yt-dlp
|
||||||
|
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
|
||||||
|
chmod a+rx /usr/local/bin/yt-dlp
|
||||||
|
|
||||||
|
# Enable pnpm
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies first for better caching
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml* ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy the rest of the application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build step if required (e.g. build:web)
|
||||||
|
RUN pnpm run build:web || true
|
||||||
|
|
||||||
|
# Set node environment
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["pnpm", "run", "start"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "frontend/src/styles.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "frontend/src/components",
|
||||||
|
"utils": "frontend/src/lib/utils",
|
||||||
|
"ui": "frontend/src/components/ui",
|
||||||
|
"lib": "frontend/src/lib",
|
||||||
|
"hooks": "frontend/src/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { ChildProcess } from "node:child_process";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import { createYtDlp } from "./src/media/ytdlp.js";
|
||||||
|
import { prepareStream } from "./src/streaming/index.js";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const ytdlp = createYtDlp();
|
||||||
|
const url = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"; // Small video
|
||||||
|
|
||||||
|
console.log("Getting direct video url...");
|
||||||
|
const directUrl = await ytdlp.getDirectVideoUrl(url);
|
||||||
|
console.log("Direct URL:", directUrl);
|
||||||
|
|
||||||
|
console.log("Preparing stream...");
|
||||||
|
const { command, output } = prepareStream(directUrl, {
|
||||||
|
logLevel: "debug",
|
||||||
|
customInputOptions: [
|
||||||
|
"-headers",
|
||||||
|
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.3\r\nConnection: keep-alive\r\n",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ffmpeg = command as ChildProcess;
|
||||||
|
ffmpeg.stderr?.on("data", (data: Buffer) => {
|
||||||
|
console.log("FFMPEG STDERR:", data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
let bytesRead = 0;
|
||||||
|
output.on("data", (chunk: Buffer) => {
|
||||||
|
bytesRead += chunk.length;
|
||||||
|
console.log("Stream bytes:", bytesRead);
|
||||||
|
if (bytesRead > 1024 * 1024) {
|
||||||
|
ffmpeg.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
ffmpeg.on("exit", (code) => {
|
||||||
|
if (code === 0 || code === null) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||||
|
});
|
||||||
|
ffmpeg.on("error", reject);
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(
|
||||||
|
"Debug stream failed:",
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test();
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration for CLI deployment
|
||||||
|
VPS_HOST="45.127.35.244"
|
||||||
|
VPS_USER="root"
|
||||||
|
# Find first available private key in ~/.ssh or use specific one if you want to hardcode
|
||||||
|
SSH_KEY_PATH=$(find ~/.ssh -name "id_rsa" -o -name "id_ed25519" | head -n 1)
|
||||||
|
|
||||||
|
echo "🚀 Starting CLI deployment to $VPS_USER@$VPS_HOST..."
|
||||||
|
|
||||||
|
if [ -z "$SSH_KEY_PATH" ]; then
|
||||||
|
echo "⚠️ No SSH key found in ~/.ssh. Falling back to default SSH behavior."
|
||||||
|
SSH_CMD="ssh -o StrictHostKeyChecking=no $VPS_USER@$VPS_HOST"
|
||||||
|
RSYNC_CMD="rsync -avz --exclude-from='.dockerignore' -e 'ssh -o StrictHostKeyChecking=no'"
|
||||||
|
else
|
||||||
|
echo "🔑 Using SSH key: $SSH_KEY_PATH"
|
||||||
|
SSH_CMD="ssh -i $SSH_KEY_PATH -o StrictHostKeyChecking=no $VPS_USER@$VPS_HOST"
|
||||||
|
RSYNC_CMD="rsync -avz --exclude-from='.dockerignore' -e 'ssh -i $SSH_KEY_PATH -o StrictHostKeyChecking=no'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Directory on the VPS where the app will be deployed
|
||||||
|
REMOTE_DIR="/opt/imphenbot"
|
||||||
|
|
||||||
|
echo "📦 Syncing files to VPS..."
|
||||||
|
$SSH_CMD "mkdir -p $REMOTE_DIR"
|
||||||
|
eval "$RSYNC_CMD ./ $VPS_USER@$VPS_HOST:$REMOTE_DIR"
|
||||||
|
|
||||||
|
if [ -f .env ]; then
|
||||||
|
echo "🔒 Copying local .env to VPS..."
|
||||||
|
if [ -z "$SSH_KEY_PATH" ]; then
|
||||||
|
scp -o StrictHostKeyChecking=no .env $VPS_USER@$VPS_HOST:$REMOTE_DIR/.env
|
||||||
|
else
|
||||||
|
scp -i $SSH_KEY_PATH -o StrictHostKeyChecking=no .env $VPS_USER@$VPS_HOST:$REMOTE_DIR/.env
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ No local .env found to copy."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔄 Rebuilding and restarting Docker containers..."
|
||||||
|
$SSH_CMD << EOF
|
||||||
|
cd $REMOTE_DIR
|
||||||
|
docker-compose up -d --build
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✅ Deployment complete!"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: imphenbot-app
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./recordings:/app/recordings
|
||||||
|
# Mapping SQLite database files if needed, or storing them in a dedicated volume.
|
||||||
|
# Assuming default config uses root directory for DB.
|
||||||
|
- ./.muxer-queue.db:/app/.muxer-queue.db
|
||||||
|
- ./.muxer-queue.db-shm:/app/.muxer-queue.db-shm
|
||||||
|
- ./.muxer-queue.db-wal:/app/.muxer-queue.db-wal
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.imphenbot.rule=Host(`imphnen.asepharyana.tech`)"
|
||||||
|
- "traefik.http.routers.imphenbot.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.imphenbot.tls=true"
|
||||||
|
# Expose port to traefik (adjust if WEBSERVER_PORT differs)
|
||||||
|
- "traefik.http.services.imphenbot.loadbalancer.server.port=3000"
|
||||||
|
networks:
|
||||||
|
- app-shared-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app-shared-net:
|
||||||
|
name: app-shared-net
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Internal Streamer Replacement Design
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Replace the external `@dank074/discord-video-stream` dependency with an internal streaming module that uses `discord.js-selfbot-v13` private APIs to deliver the same screen share behavior (video + audio) with identical UI/API surface.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Maintain feature parity for screen share (video + audio, 720p @ 30fps, bitrate 2500/4000, H264, audio on).
|
||||||
|
- Keep existing UI and API contracts unchanged (`/api/media/queue` with `mode: "screen"`).
|
||||||
|
- Remove `@dank074/discord-video-stream` from dependencies and delete `vendor/Discord-video-stream`.
|
||||||
|
- Ensure clean lifecycle handling (start/stop, cleanup, error reporting).
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
- Rewriting WebRTC/RTP stack from scratch.
|
||||||
|
- Changing media queue behavior or UI layout.
|
||||||
|
- Adding new screen share modes or settings.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
Introduce a new internal module under `src/streaming/` that encapsulates:
|
||||||
|
- Voice/session management using private `discord.js-selfbot-v13` APIs.
|
||||||
|
- FFmpeg preparation for H264 + Opus (AnnexB video + Opus audio).
|
||||||
|
- Stream playback into the internal dispatcher.
|
||||||
|
|
||||||
|
`screenShareController` will depend on this module instead of `@dank074/discord-video-stream`.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1) Streaming Session Module (`src/streaming/`)
|
||||||
|
Proposed exports:
|
||||||
|
- `createStreamSession(client)`
|
||||||
|
- Joins or reuses voice connection for video streaming.
|
||||||
|
- Exposes a `session` object with `startVideo()`, `stopVideo()`, and `sendStream(stream)` hooks.
|
||||||
|
- `prepareFfmpegStream(source, opts)`
|
||||||
|
- Spawns ffmpeg with the same parameters used today.
|
||||||
|
- Returns `{ command, output }` (output is a Readable stream).
|
||||||
|
- `playPreparedStream(output, session)`
|
||||||
|
- Pipes the prepared stream into the internal dispatcher.
|
||||||
|
- Returns a promise that resolves when playback completes.
|
||||||
|
|
||||||
|
### 2) Screen Share Controller (`src/media/screenShareController.ts`)
|
||||||
|
- Replace Streamer/prepareStream/playStream with internal module usage.
|
||||||
|
- Keep the public API identical (`start(source)` returning `ScreenSharePlayback`).
|
||||||
|
|
||||||
|
### 3) Web Server Wiring (`src/webserver.ts`)
|
||||||
|
- Remove `Streamer` instantiation and dependencies.
|
||||||
|
- Pass only `getVoiceStatus` and new streaming module dependencies into `createScreenShareController`.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
1. User queues screen share via `/api/media/queue` with `mode: "screen"`.
|
||||||
|
2. `MediaController` calls `screenShareController.start(source)`.
|
||||||
|
3. `screenShareController` resolves URL, calls `prepareFfmpegStream`.
|
||||||
|
4. `createStreamSession` ensures voice connection and dispatcher ready.
|
||||||
|
5. `playPreparedStream` sends output to Discord.
|
||||||
|
6. On completion or stop, cleanup runs and state updates propagate.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
- Voice not connected: throw `VOICE_NOT_CONNECTED`.
|
||||||
|
- FFmpeg spawn/exit failure: throw `SCREEN_STREAM_FAILED`.
|
||||||
|
- Dispatcher error: stop stream, cleanup, log error, set state idle.
|
||||||
|
|
||||||
|
## Lifecycle Rules
|
||||||
|
- `start()` always stops any active stream first.
|
||||||
|
- `stop()` kills ffmpeg, stops dispatcher, and resets internal state.
|
||||||
|
- Completion resolves `done` promise and triggers cleanup.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
- Unit tests for `screenShareController`:
|
||||||
|
- Calls to `prepareFfmpegStream` and `playPreparedStream` on `start()`.
|
||||||
|
- Ensures `stop()` kills ffmpeg and ends session.
|
||||||
|
- Unit tests for `streaming` module:
|
||||||
|
- Session initialization and cleanup logic with mocked private APIs.
|
||||||
|
|
||||||
|
## Migration Steps
|
||||||
|
1. Implement `src/streaming/` module.
|
||||||
|
2. Update `screenShareController` to use internal module.
|
||||||
|
3. Remove `@dank074/discord-video-stream` imports and wiring.
|
||||||
|
4. Delete `vendor/Discord-video-stream` directory.
|
||||||
|
5. Update `package.json` dependencies.
|
||||||
|
6. Update tests.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- Private `discord.js-selfbot-v13` APIs may change.
|
||||||
|
- Harder debugging if internal dispatcher behavior differs.
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
- Revert to previous commit that restores `@dank074/discord-video-stream` and the vendor directory.
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Discord Moderation Dashboard</title>
|
<title>Discord Moderation Dashboard</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
+239
-104
@@ -1,124 +1,259 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { listMessages, reanalyzeMessage } from "./api/client";
|
import { DashboardLayout } from "./components/layout/DashboardLayout";
|
||||||
import { connectDashboardSocket } from "./ws/client";
|
import { MediaPanel } from "./components/media/MediaPanel";
|
||||||
import type { MessageRecord } from "./api/client";
|
import { MessagesPanel } from "./components/messages/MessagesPanel";
|
||||||
import type { DashboardEvent } from "./ws/client";
|
|
||||||
import { MessageFeed } from "./components/messages/MessageFeed";
|
|
||||||
import { ReviewPanel } from "./components/review/ReviewPanel";
|
import { ReviewPanel } from "./components/review/ReviewPanel";
|
||||||
|
import { Tabs, TabsContent } from "./components/ui/tabs";
|
||||||
|
import { VoicePanel } from "./components/voice/VoicePanel";
|
||||||
|
import { AuthOverlay } from "./components/layout/AuthOverlay";
|
||||||
|
import { useDashboardSocket } from "./hooks/useDashboardSocket";
|
||||||
|
import { mergeMessages, useMessages } from "./hooks/useMessages";
|
||||||
|
import { useMediaControl } from "./hooks/useMediaControl";
|
||||||
|
import { useUIState } from "./hooks/useUIState";
|
||||||
|
import { useVoiceControl } from "./hooks/useVoiceControl";
|
||||||
|
import type { MessageRecord } from "./types/messages";
|
||||||
|
import type { DashboardTab } from "./types/ui";
|
||||||
|
import type { ActiveSpeaker } from "./types/voice";
|
||||||
|
|
||||||
function mergeMessages(
|
const SAMPLE_RATE = 24000;
|
||||||
current: MessageRecord[],
|
const CHANNELS = 1;
|
||||||
incoming: MessageRecord[],
|
|
||||||
): MessageRecord[] {
|
|
||||||
const byId = new Map(current.map((message) => [message.id, message]));
|
|
||||||
for (const message of incoming) {
|
|
||||||
byId.set(message.id, { ...byId.get(message.id), ...message });
|
|
||||||
}
|
|
||||||
return Array.from(byId.values())
|
|
||||||
.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
|
|
||||||
.slice(0, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
const { uiState, setUIState, patchUIState } = useUIState();
|
||||||
const [wsStatus, setWsStatus] = useState<string>("connecting");
|
const voice = useVoiceControl();
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const media = useMediaControl();
|
||||||
|
const messages = useMessages();
|
||||||
|
const [activeSpeakers, setActiveSpeakers] = useState<ActiveSpeaker[]>([]);
|
||||||
|
const [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04));
|
||||||
|
const [isListening, setIsListening] = useState(false);
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem("admin-password"));
|
||||||
|
const audioContextListenRef = useRef<AudioContext | null>(null);
|
||||||
|
const audioContextTransmitRef = useRef<AudioContext | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const processorRef = useRef<ScriptProcessorNode | null>(null);
|
||||||
|
const userTimelinesRef = useRef(new Map<number, number>());
|
||||||
|
|
||||||
useEffect(() => {
|
const activeTab = uiState.activeTab || "voice";
|
||||||
let cancelled = false;
|
const selectedVoiceGuild = uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
||||||
|
const selectedVoiceChannel = uiState.selectedVoiceChannel || "";
|
||||||
|
const selectedTextGuild = uiState.selectedTextGuild || uiState.selectedGuild || "";
|
||||||
|
const selectedTextChannel = uiState.selectedTextChannel || "";
|
||||||
|
|
||||||
listMessages(new URLSearchParams({ limit: "30" }))
|
const handleIncomingPcm = useCallback((data: ArrayBuffer) => {
|
||||||
.then((result) => {
|
const headerView = new DataView(data, 0, 4);
|
||||||
if (!cancelled) {
|
const userIdHash = headerView.getInt32(0, true);
|
||||||
setMessages(mergeMessages([], result.data));
|
const audioData = data.slice(4);
|
||||||
}
|
const int16Array = new Int16Array(audioData);
|
||||||
})
|
let sum = 0;
|
||||||
.catch((err) => {
|
for (const sample of int16Array) sum += Math.abs(sample / 32768);
|
||||||
if (!cancelled) {
|
const average = int16Array.length ? sum / int16Array.length : 0;
|
||||||
console.error("Failed to load messages:", err);
|
setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5)));
|
||||||
}
|
|
||||||
|
const audioContext = audioContextListenRef.current;
|
||||||
|
if (!isListening || !audioContext) return;
|
||||||
|
const float32Array = new Float32Array(int16Array.length);
|
||||||
|
for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768;
|
||||||
|
const audioBuffer = audioContext.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE);
|
||||||
|
audioBuffer.getChannelData(0).set(float32Array);
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(audioContext.destination);
|
||||||
|
const currentTime = audioContext.currentTime;
|
||||||
|
let nextStart = userTimelinesRef.current.get(userIdHash) || 0;
|
||||||
|
if (nextStart < currentTime) nextStart = currentTime + 0.05;
|
||||||
|
source.start(nextStart);
|
||||||
|
userTimelinesRef.current.set(userIdHash, nextStart + audioBuffer.duration);
|
||||||
|
}, [isListening]);
|
||||||
|
|
||||||
|
const socket = useDashboardSocket({
|
||||||
|
onUIState: (state) => setUIState((prev) => ({ ...prev, ...state })),
|
||||||
|
onUserState: setActiveSpeakers,
|
||||||
|
onMessageCreated: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
|
||||||
|
onMessageUpdated: (message) => messages.setMessages((prev) => prev.map((item) => (item.id === message.id ? { ...item, ...message } as MessageRecord : item))),
|
||||||
|
onMessageDeleted: (message) => messages.setMessages((prev) => prev.map((item) => (item.id === message.id ? { ...item, type: "deleted" } : item))),
|
||||||
|
onMessageAnalyzed: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
|
||||||
|
onAttachmentUploaded: () => messages.fetchMessages(selectedTextChannel).catch(() => undefined),
|
||||||
|
onMediaState: media.setMediaState,
|
||||||
|
onPcm: handleIncomingPcm,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ws = connectDashboardSocket((event: DashboardEvent) => {
|
const stopStreamingLocal = useCallback(() => {
|
||||||
switch (event.type) {
|
setIsStreaming(false);
|
||||||
case "message_created":
|
if (processorRef.current) {
|
||||||
setMessages((prev) => mergeMessages(prev, [event.data]));
|
processorRef.current.disconnect();
|
||||||
break;
|
processorRef.current = null;
|
||||||
case "message_analyzed":
|
|
||||||
setMessages((prev) => mergeMessages(prev, [event.data]));
|
|
||||||
break;
|
|
||||||
case "message_updated":
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) => (m.id === event.data.id ? { ...m, ...event.data } : m)),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "message_deleted":
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === event.data.id ? { ...m, type: "deleted" as const } : m,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
});
|
if (audioContextTransmitRef.current) {
|
||||||
|
audioContextTransmitRef.current.close();
|
||||||
wsRef.current = ws;
|
audioContextTransmitRef.current = null;
|
||||||
|
}
|
||||||
ws.addEventListener("open", () => setWsStatus("connected"));
|
if (streamRef.current) {
|
||||||
ws.addEventListener("close", () => setWsStatus("disconnected"));
|
for (const track of streamRef.current.getTracks()) track.stop();
|
||||||
ws.addEventListener("error", () => setWsStatus("error"));
|
streamRef.current = null;
|
||||||
|
}
|
||||||
return () => {
|
setLevels(Array.from({ length: 32 }, () => 0.04));
|
||||||
cancelled = true;
|
|
||||||
ws.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleReanalyze = async (id: string) => {
|
const startStreamingLocal = useCallback(async () => {
|
||||||
// Optimistic update
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === id
|
|
||||||
? { ...m, ai_status: "pending" as const, ai_error: null, ai_analysis: null }
|
|
||||||
: m,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reanalyzeMessage(id);
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
} catch (err) {
|
streamRef.current = stream;
|
||||||
console.error("Reanalyze failed:", err);
|
setIsStreaming(true);
|
||||||
// Revert optimistic update on failure
|
|
||||||
setMessages((prev) =>
|
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||||
prev.map((m) =>
|
const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE });
|
||||||
m.id === id ? { ...m, ai_status: "error" as const, ai_error: "Reanalyze failed" } : m,
|
audioContextTransmitRef.current = audioContext;
|
||||||
),
|
|
||||||
);
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
|
processorRef.current = processor;
|
||||||
|
|
||||||
|
source.connect(processor);
|
||||||
|
processor.connect(audioContext.destination);
|
||||||
|
|
||||||
|
processor.onaudioprocess = (event) => {
|
||||||
|
if (!socket.socketRef.current || socket.socketRef.current.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const inputData = event.inputBuffer.getChannelData(0);
|
||||||
|
const pcmData = new Int16Array(inputData.length);
|
||||||
|
for (let i = 0; i < inputData.length; i++) {
|
||||||
|
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
||||||
}
|
}
|
||||||
|
socket.socketRef.current.send(pcmData.buffer);
|
||||||
|
|
||||||
|
// Update local levels from mic
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < inputData.length; i++) sum += Math.abs(inputData[i]);
|
||||||
|
const average = inputData.length ? sum / inputData.length : 0;
|
||||||
|
setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5)));
|
||||||
};
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Microphone access failed:", err);
|
||||||
|
setIsStreaming(false);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [socket.socketRef]);
|
||||||
|
|
||||||
|
const toggleStreaming = useCallback(async () => {
|
||||||
|
if (isStreaming) {
|
||||||
|
stopStreamingLocal();
|
||||||
|
await patchUIState({ isStreaming: false });
|
||||||
|
} else {
|
||||||
|
await startStreamingLocal();
|
||||||
|
await patchUIState({ isStreaming: true });
|
||||||
|
}
|
||||||
|
}, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedVoiceGuild) {
|
||||||
|
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, [selectedVoiceGuild]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTextGuild) {
|
||||||
|
voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, [selectedTextGuild]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTextChannel) {
|
||||||
|
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, [selectedTextChannel]);
|
||||||
|
|
||||||
|
const toggleListening = useCallback(async () => {
|
||||||
|
if (isListening) {
|
||||||
|
await audioContextListenRef.current?.suspend();
|
||||||
|
userTimelinesRef.current.clear();
|
||||||
|
setIsListening(false);
|
||||||
|
await patchUIState({ isListening: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||||
|
audioContextListenRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
|
||||||
|
await audioContextListenRef.current.resume();
|
||||||
|
setIsListening(true);
|
||||||
|
await patchUIState({ isListening: true });
|
||||||
|
}, [isListening, patchUIState]);
|
||||||
|
|
||||||
|
const tabs = useMemo(() => ["voice", "media", "messages", "review"] as DashboardTab[], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<DashboardLayout
|
||||||
<div className="sidebar">
|
activeTab={activeTab}
|
||||||
<div className="sidebar-header">Moderation</div>
|
wsStatus={socket.status}
|
||||||
<div className="sidebar-placeholder">Channels placeholder</div>
|
voiceStatus={voice.voiceStatus}
|
||||||
</div>
|
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||||
|
>
|
||||||
<div className="main">
|
<div className="md:hidden">
|
||||||
<div className="header">
|
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||||
<h1>Discord Moderation Dashboard</h1>
|
<div className="mb-4 grid grid-cols-4 gap-2 rounded-2xl bg-muted p-1">
|
||||||
<span className="ws-status" data-status={wsStatus}>
|
{tabs.map((tab) => (
|
||||||
{wsStatus}
|
<button key={tab} className={`rounded-xl px-2 py-2 text-xs font-medium ${activeTab === tab ? "bg-background text-foreground" : "text-muted-foreground"}`} onClick={() => patchUIState({ activeTab: tab })}>
|
||||||
</span>
|
{tab}
|
||||||
</div>
|
</button>
|
||||||
|
))}
|
||||||
<div className="content">
|
|
||||||
<MessageFeed messages={messages} onReanalyze={handleReanalyze} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReviewPanel messages={messages} onReanalyze={handleReanalyze} />
|
|
||||||
</div>
|
</div>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||||
|
<TabsContent value="voice">
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||||
|
) : (
|
||||||
|
<VoicePanel
|
||||||
|
guilds={voice.guilds}
|
||||||
|
channels={voice.voiceChannels}
|
||||||
|
selectedGuild={selectedVoiceGuild}
|
||||||
|
selectedChannel={selectedVoiceChannel}
|
||||||
|
status={voice.voiceStatus}
|
||||||
|
loading={voice.loading}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
levels={levels}
|
||||||
|
isListening={isListening}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
|
||||||
|
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
|
||||||
|
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
|
||||||
|
onDisconnect={() => voice.leaveVoice()}
|
||||||
|
onListenToggle={toggleListening}
|
||||||
|
onStreamingToggle={toggleStreaming}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="media">
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||||
|
) : (
|
||||||
|
<MediaPanel
|
||||||
|
state={media.mediaState}
|
||||||
|
loading={media.loading}
|
||||||
|
onQueueMusic={(source) => media.enqueue(source, "music")}
|
||||||
|
onStartScreen={(source) => media.enqueue(source, "screen")}
|
||||||
|
onSkip={media.skip}
|
||||||
|
onStop={media.stop}
|
||||||
|
onVolumeChange={media.setVolume}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="messages">
|
||||||
|
<MessagesPanel
|
||||||
|
guilds={voice.guilds}
|
||||||
|
channels={voice.textChannels}
|
||||||
|
selectedGuild={selectedTextGuild}
|
||||||
|
selectedChannel={selectedTextChannel}
|
||||||
|
messages={messages.messages}
|
||||||
|
onGuildChange={(guildId) => patchUIState({ selectedTextGuild: guildId, selectedTextChannel: "" })}
|
||||||
|
onChannelChange={(channelId) => patchUIState({ selectedTextChannel: channelId })}
|
||||||
|
onReanalyze={messages.reanalyze}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="review">
|
||||||
|
<ReviewPanel messages={messages.messages} onReanalyze={messages.reanalyze} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { request } from "./client";
|
||||||
|
|
||||||
|
export async function login(password: string): Promise<{ ok: boolean }> {
|
||||||
|
return request<{ ok: boolean }>('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -49,9 +49,13 @@ class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const password = localStorage.getItem("admin-password");
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(password ? { "X-Admin-Password": password } : {}),
|
||||||
|
},
|
||||||
...init,
|
...init,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { request } from "./client";
|
||||||
|
import type { MediaMode, MediaState } from "../types/media";
|
||||||
|
|
||||||
|
export function getMediaStatus(): Promise<MediaState> {
|
||||||
|
return request<MediaState>('/api/media/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueMedia(source: string, mode: MediaMode): Promise<MediaState> {
|
||||||
|
return request<MediaState>('/api/media/queue', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ source, mode }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skipMedia(): Promise<MediaState> {
|
||||||
|
return request<MediaState>('/api/media/skip', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopMedia(): Promise<MediaState> {
|
||||||
|
return request<MediaState>('/api/media/stop', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMediaVolume(volume: number): Promise<MediaState> {
|
||||||
|
return request<MediaState>('/api/media/volume', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ volume }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { listMessages, listReview, reanalyzeMessage } from "./client";
|
||||||
|
|
||||||
|
export { listMessages, listReview, reanalyzeMessage };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { request } from "./client";
|
||||||
|
import type { UIState } from "../types/ui";
|
||||||
|
|
||||||
|
export function getUIState(): Promise<UIState> {
|
||||||
|
return request<UIState>('/api/ui-state');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUIState(patch: Partial<UIState>): Promise<UIState> {
|
||||||
|
return request<UIState>('/api/ui-state', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { request } from "./client";
|
||||||
|
import type { Channel, Guild, VoiceStatus } from "../types/voice";
|
||||||
|
|
||||||
|
export function getGuilds(): Promise<Guild[]> {
|
||||||
|
return request<Guild[]>('/api/guilds');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVoiceChannels(guildId: string): Promise<Channel[]> {
|
||||||
|
return request<Channel[]>(`/api/guilds/${guildId}/voice-channels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextChannels(guildId: string): Promise<Channel[]> {
|
||||||
|
return request<Channel[]>(`/api/guilds/${guildId}/channels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVoiceStatus(): Promise<VoiceStatus> {
|
||||||
|
return request<VoiceStatus>('/api/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectVoice(guildId: string, channelId: string): Promise<VoiceStatus> {
|
||||||
|
return request<VoiceStatus>('/api/connect', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ guildId, channelId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnectVoice(): Promise<VoiceStatus> {
|
||||||
|
return request<VoiceStatus>('/api/disconnect', { method: 'POST' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { login } from "../../api/auth";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Lock } from "lucide-react";
|
||||||
|
|
||||||
|
interface AuthOverlayProps {
|
||||||
|
onAuthenticated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await login(password);
|
||||||
|
localStorage.setItem("admin-password", password);
|
||||||
|
onAuthenticated();
|
||||||
|
} catch (err) {
|
||||||
|
setError("Invalid password");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<Lock className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Admin Access Required</CardTitle>
|
||||||
|
<CardDescription>Enter the admin password to access Voice and Media controls.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading || !password}>
|
||||||
|
{loading ? "Authenticating..." : "Unlock Controls"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { DashboardTab } from "../../types/ui";
|
||||||
|
import type { VoiceStatus } from "../../types/voice";
|
||||||
|
import type { WebSocketStatus } from "../../hooks/useDashboardSocket";
|
||||||
|
import { Header } from "./Header";
|
||||||
|
import { Sidebar } from "./Sidebar";
|
||||||
|
|
||||||
|
interface DashboardLayoutProps {
|
||||||
|
activeTab: DashboardTab;
|
||||||
|
wsStatus: WebSocketStatus;
|
||||||
|
voiceStatus: VoiceStatus;
|
||||||
|
onTabChange: (tab: DashboardTab) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardLayout({ activeTab, wsStatus, voiceStatus, onTabChange, children }: DashboardLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<Sidebar activeTab={activeTab} onTabChange={onTabChange} />
|
||||||
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<Header activeTab={activeTab} wsStatus={wsStatus} voiceStatus={voiceStatus} />
|
||||||
|
<div className="flex-1 overflow-auto p-4 md:p-6 lg:p-8">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Wifi, WifiOff } from "lucide-react";
|
||||||
|
import type { WebSocketStatus } from "../../hooks/useDashboardSocket";
|
||||||
|
import type { DashboardTab } from "../../types/ui";
|
||||||
|
import type { VoiceStatus } from "../../types/voice";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
|
||||||
|
const titles: Record<DashboardTab, string> = {
|
||||||
|
voice: "Voice Control",
|
||||||
|
media: "Media Player",
|
||||||
|
messages: "Messages",
|
||||||
|
review: "Moderation Review",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
activeTab: DashboardTab;
|
||||||
|
wsStatus: WebSocketStatus;
|
||||||
|
voiceStatus: VoiceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 px-4 py-4 backdrop-blur md:px-8">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{titles[activeTab]}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Voice, media, and moderation in one dashboard.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant={wsStatus === "connected" ? "success" : wsStatus === "error" ? "destructive" : "warning"}>
|
||||||
|
{wsStatus === "connected" ? <Wifi className="mr-1 h-3 w-3" /> : <WifiOff className="mr-1 h-3 w-3" />}
|
||||||
|
WebSocket {wsStatus}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={voiceStatus.connected ? "success" : "secondary"}>
|
||||||
|
Voice {voiceStatus.connected ? voiceStatus.activeChannelName || "connected" : "idle"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Bot, MessageSquare, Music2, ShieldAlert, Volume2 } from "lucide-react";
|
||||||
|
import type { DashboardTab } from "../../types/ui";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
|
const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Volume2 }> = [
|
||||||
|
{ id: "voice", label: "Voice", icon: Volume2 },
|
||||||
|
{ id: "media", label: "Media", icon: Music2 },
|
||||||
|
{ id: "messages", label: "Messages", icon: MessageSquare },
|
||||||
|
{ id: "review", label: "Review", icon: ShieldAlert },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
activeTab: DashboardTab;
|
||||||
|
onTabChange: (tab: DashboardTab) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
|
||||||
|
return (
|
||||||
|
<aside className="hidden w-72 shrink-0 border-r border-border bg-card/60 p-5 backdrop-blur md:block">
|
||||||
|
<div className="mb-8 flex items-center gap-3">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/15 text-primary">
|
||||||
|
<Bot className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold tracking-tight">Bete Watcher</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Discord control center</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="space-y-2">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={item.id}
|
||||||
|
variant={activeTab === item.id ? "secondary" : "ghost"}
|
||||||
|
className={cn("w-full justify-start", activeTab === item.id && "bg-primary/15 text-primary")}
|
||||||
|
onClick={() => onTabChange(item.id)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { MediaState } from "../../types/media";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||||
|
import { MediaQueue } from "./MediaQueue";
|
||||||
|
import { MusicPlayer } from "./MusicPlayer";
|
||||||
|
import { ScreenShare } from "./ScreenShare";
|
||||||
|
|
||||||
|
interface MediaPanelProps {
|
||||||
|
state: MediaState;
|
||||||
|
loading: boolean;
|
||||||
|
onQueueMusic: (source: string) => void;
|
||||||
|
onStartScreen: (source: string) => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
onVolumeChange: (volume: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaPanel({
|
||||||
|
state,
|
||||||
|
loading,
|
||||||
|
onQueueMusic,
|
||||||
|
onStartScreen,
|
||||||
|
onSkip,
|
||||||
|
onStop,
|
||||||
|
onVolumeChange,
|
||||||
|
}: MediaPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1fr_380px]">
|
||||||
|
<Tabs defaultValue="music" className="min-w-0">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="music">Music</TabsTrigger>
|
||||||
|
<TabsTrigger value="screen">Screen Share</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="music">
|
||||||
|
<MusicPlayer
|
||||||
|
loading={loading}
|
||||||
|
volume={state.musicVolume}
|
||||||
|
onVolumeChange={onVolumeChange}
|
||||||
|
onQueue={onQueueMusic}
|
||||||
|
onSkip={onSkip}
|
||||||
|
onStop={onStop}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="screen">
|
||||||
|
<ScreenShare loading={loading} onStart={onStartScreen} onSkip={onSkip} onStop={onStop} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
<MediaQueue state={state} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { MediaState } from "../../types/media";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
|
||||||
|
interface MediaQueueProps {
|
||||||
|
state: MediaState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaQueue({ state }: MediaQueueProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Now Playing</CardTitle>
|
||||||
|
<CardDescription>Current item and queue state.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{state.current ? (
|
||||||
|
<div className="rounded-xl border border-primary/30 bg-primary/10 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">{state.current.title}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{state.current.source}</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={state.current.mode === "screen" ? "warning" : "success"}>{state.current.mode || "music"}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">No media playing.</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">Queue</div>
|
||||||
|
{state.queue.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">Queue is empty.</div>
|
||||||
|
) : (
|
||||||
|
state.queue.map((item, index) => (
|
||||||
|
<div key={`${item.source}-${index}`} className="rounded-lg border border-border bg-background/60 p-3 text-sm">
|
||||||
|
<div className="font-medium">{item.title}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{item.source}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Music2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
|
||||||
|
interface MusicPlayerProps {
|
||||||
|
loading: boolean;
|
||||||
|
volume: number;
|
||||||
|
onVolumeChange: (volume: number) => void;
|
||||||
|
onQueue: (source: string) => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MusicPlayer({
|
||||||
|
loading,
|
||||||
|
volume,
|
||||||
|
onVolumeChange,
|
||||||
|
onQueue,
|
||||||
|
onSkip,
|
||||||
|
onStop,
|
||||||
|
}: MusicPlayerProps) {
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
const safeVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
|
||||||
|
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftVolume(Math.round(safeVolume * 100));
|
||||||
|
}, [safeVolume]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const normalized = draftVolume / 100;
|
||||||
|
if (Math.abs(normalized - safeVolume) < 0.001) return;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
onVolumeChange(normalized);
|
||||||
|
}, 150);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [draftVolume, onVolumeChange, safeVolume]);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const trimmed = source.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
onQueue(trimmed);
|
||||||
|
setSource("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Music2 className="h-5 w-5" /> Music Player</CardTitle>
|
||||||
|
<CardDescription>Play YouTube, Spotify tracks, search terms, or local files as audio.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Input
|
||||||
|
value={source}
|
||||||
|
onChange={(event) => setSource(event.target.value)}
|
||||||
|
onKeyDown={(event) => event.key === "Enter" && submit()}
|
||||||
|
placeholder="YouTube URL, Spotify track, or search terms"
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">Volume</span>
|
||||||
|
<span className="text-muted-foreground">{draftVolume}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={draftVolume}
|
||||||
|
onChange={(event) => setDraftVolume(Number(event.target.value))}
|
||||||
|
className="h-2 w-full cursor-pointer accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button disabled={loading || !source.trim()} onClick={submit}>Queue / Play</Button>
|
||||||
|
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</Button>
|
||||||
|
<Button variant="destructive" disabled={loading} onClick={onStop}>Stop</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { MonitorUp } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
|
||||||
|
interface ScreenShareProps {
|
||||||
|
loading: boolean;
|
||||||
|
onStart: (source: string) => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScreenShare({ loading, onStart, onSkip, onStop }: ScreenShareProps) {
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const trimmed = source.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
onStart(trimmed);
|
||||||
|
setSource("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><MonitorUp className="h-5 w-5" /> Screen Share</CardTitle>
|
||||||
|
<CardDescription>Start screen-share playback from a URL or local file path.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Input
|
||||||
|
value={source}
|
||||||
|
onChange={(event) => setSource(event.target.value)}
|
||||||
|
onKeyDown={(event) => event.key === "Enter" && submit()}
|
||||||
|
placeholder="Screen share URL or local file path"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button disabled={loading || !source.trim()} onClick={submit}>Start Screen Share</Button>
|
||||||
|
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</Button>
|
||||||
|
<Button variant="destructive" disabled={loading} onClick={onStop}>Stop</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { MessageMetadata, MessageRecord } from "../../types/messages";
|
||||||
|
|
||||||
|
function parseMetadata(value: string | null): MessageMetadata {
|
||||||
|
if (!value) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as MessageMetadata;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
||||||
|
const images = messages.flatMap((message) => {
|
||||||
|
const metadata = parseMetadata(message.metadata);
|
||||||
|
const attachments = metadata.attachments ?? [];
|
||||||
|
const embeds = metadata.embeds ?? [];
|
||||||
|
return [
|
||||||
|
...attachments
|
||||||
|
.filter((attachment) => attachment.url && (attachment.contentType?.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(attachment.name)))
|
||||||
|
.map((attachment) => ({ url: attachment.url, title: attachment.name, message })),
|
||||||
|
...embeds
|
||||||
|
.flatMap((embed) => [embed.image, embed.thumbnail].filter(Boolean).map((url) => ({ url: url as string, title: embed.title || "embed image", message }))),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
return <div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">No images found.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
|
{images.map((image, index) => (
|
||||||
|
<a key={`${image.url}-${index}`} href={image.url} target="_blank" rel="noreferrer" className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
|
||||||
|
<img src={image.url} alt={image.title} className="aspect-video w-full object-cover transition-transform group-hover:scale-105" />
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="truncate text-sm font-medium">{image.title}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{image.message.username}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,74 +1,51 @@
|
|||||||
import type { MessageRecord } from "../../api/client";
|
import { RotateCw } from "lucide-react";
|
||||||
|
import type { MessageRecord } from "../../types/messages";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
export interface MessageCardProps {
|
export interface MessageCardProps {
|
||||||
message: MessageRecord;
|
message: MessageRecord;
|
||||||
onReanalyze: (id: string) => void;
|
onReanalyze: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
function aiVariant(status: string) {
|
||||||
pending: "#f9e2af",
|
if (status === "clean") return "success";
|
||||||
clean: "#a6e3a1",
|
if (status === "warn") return "warning";
|
||||||
warn: "#fab387",
|
if (status === "flagged" || status === "error") return "destructive";
|
||||||
flagged: "#f38ba8",
|
return "secondary";
|
||||||
error: "#f38ba8",
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
||||||
const displayContent = message.edited_content ?? message.content;
|
const displayContent = message.edited_content ?? message.content;
|
||||||
const aiStatus = message.ai_status ?? "pending";
|
const aiStatus = message.ai_status ?? "pending";
|
||||||
const statusColor = STATUS_COLORS[aiStatus] ?? "#6c7086";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`message-card type-${message.type}`}>
|
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
|
||||||
|
<div className="flex gap-3">
|
||||||
<img
|
<img
|
||||||
src={message.avatar_url ?? "/default-avatar.png"}
|
src={message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
|
||||||
alt={message.username}
|
alt=""
|
||||||
className="message-card-avatar"
|
className="h-10 w-10 rounded-full object-cover"
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
/>
|
/>
|
||||||
<div className="message-card-body">
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
<div className="message-card-meta">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="message-card-username">{message.username}</span>
|
<span className="font-medium">{message.username || message.user_id}</span>
|
||||||
<span className="message-card-time">
|
<span className="text-xs text-muted-foreground">{new Date(message.created_at).toLocaleString()}</span>
|
||||||
{new Date(message.created_at).toLocaleString()}
|
{message.edited_at ? <Badge variant="outline">edited</Badge> : null}
|
||||||
</span>
|
{message.deleted_at ? <Badge variant="destructive">deleted</Badge> : null}
|
||||||
{message.type === "edited" && (
|
<Badge variant={aiVariant(aiStatus)}>{aiStatus}</Badge>
|
||||||
<span className="badge badge-edited">edited</span>
|
|
||||||
)}
|
|
||||||
{message.type === "deleted" && (
|
|
||||||
<span className="badge badge-deleted">deleted</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="badge badge-ai"
|
|
||||||
style={{ backgroundColor: statusColor }}
|
|
||||||
title={`AI: ${aiStatus}`}
|
|
||||||
>
|
|
||||||
{aiStatus}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="message-card-content">{displayContent}</p>
|
|
||||||
|
|
||||||
{message.ai_analysis && (
|
|
||||||
<div className="message-card-analysis">{message.ai_analysis}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{message.ai_error && (
|
|
||||||
<div className="message-card-error">{message.ai_error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="message-card-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-reanalyze"
|
|
||||||
onClick={() => onReanalyze(message.id)}
|
|
||||||
disabled={aiStatus === "pending"}
|
|
||||||
>
|
|
||||||
Reanalyze
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
|
||||||
|
{displayContent || "(empty message)"}
|
||||||
|
</p>
|
||||||
|
{message.ai_analysis ? <div className="rounded-xl bg-muted p-3 text-sm text-muted-foreground">{message.ai_analysis}</div> : null}
|
||||||
|
{message.ai_error ? <div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">AI error: {message.ai_error}</div> : null}
|
||||||
|
<Button size="sm" variant="outline" onClick={() => onReanalyze(message.id)} disabled={aiStatus === "pending"}>
|
||||||
|
<RotateCw className="h-3.5 w-3.5" />
|
||||||
|
Re-analyze
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import type { MessageRecord } from "../../api/client";
|
import type { MessageRecord } from "../../types/messages";
|
||||||
|
import { ScrollArea } from "../ui/scroll-area";
|
||||||
import { MessageCard } from "./MessageCard";
|
import { MessageCard } from "./MessageCard";
|
||||||
|
|
||||||
export interface MessageFeedProps {
|
export interface MessageFeedProps {
|
||||||
messages: MessageRecord[];
|
messages: MessageRecord[];
|
||||||
onReanalyze: (id: string) => void;
|
onReanalyze: (id: string) => void;
|
||||||
|
emptyText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageFeed({ messages, onReanalyze }: MessageFeedProps) {
|
export function MessageFeed({ messages, onReanalyze, emptyText = "No messages found." }: MessageFeedProps) {
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return (
|
return <div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">{emptyText}</div>;
|
||||||
<div className="empty-state">
|
|
||||||
<p>No messages yet</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message-feed">
|
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||||
{messages.map((msg) => (
|
<div className="space-y-3">
|
||||||
<MessageCard key={msg.id} message={msg} onReanalyze={onReanalyze} />
|
{messages.map((message) => (
|
||||||
|
<MessageCard key={message.id} message={message} onReanalyze={onReanalyze} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Channel, Guild } from "../../types/voice";
|
||||||
|
import type { MessageRecord } from "../../types/messages";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Select } from "../ui/select";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||||
|
import { ImageGrid } from "./ImageGrid";
|
||||||
|
import { MessageFeed } from "./MessageFeed";
|
||||||
|
|
||||||
|
interface MessagesPanelProps {
|
||||||
|
guilds: Guild[];
|
||||||
|
channels: Channel[];
|
||||||
|
selectedGuild: string;
|
||||||
|
selectedChannel: string;
|
||||||
|
messages: MessageRecord[];
|
||||||
|
onGuildChange: (guildId: string) => void;
|
||||||
|
onChannelChange: (channelId: string) => void;
|
||||||
|
onReanalyze: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessagesPanel({
|
||||||
|
guilds,
|
||||||
|
channels,
|
||||||
|
selectedGuild,
|
||||||
|
selectedChannel,
|
||||||
|
messages,
|
||||||
|
onGuildChange,
|
||||||
|
onChannelChange,
|
||||||
|
onReanalyze,
|
||||||
|
}: MessagesPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Message Source</CardTitle>
|
||||||
|
<CardDescription>Pick a guild and channel/thread to inspect captures.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Select
|
||||||
|
value={selectedGuild}
|
||||||
|
onChange={(event) => onGuildChange(event.target.value)}
|
||||||
|
placeholder="Select text guild"
|
||||||
|
options={guilds.map((guild) => ({ value: guild.id, label: guild.name }))}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={selectedChannel}
|
||||||
|
onChange={(event) => onChannelChange(event.target.value)}
|
||||||
|
placeholder="Select channel or thread"
|
||||||
|
options={channels.map((channel) => ({ value: channel.id, label: channel.name }))}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Tabs defaultValue="all">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">All Messages</TabsTrigger>
|
||||||
|
<TabsTrigger value="images">Images</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="all">
|
||||||
|
<MessageFeed messages={messages} onReanalyze={onReanalyze} emptyText={selectedChannel ? "No captures yet." : "Select a channel to view captures."} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="images">
|
||||||
|
<ImageGrid messages={messages} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { MessageRecord } from "../../api/client";
|
import type { MessageRecord } from "../../types/messages";
|
||||||
import { MessageCard } from "../messages/MessageCard";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { MessageFeed } from "../messages/MessageFeed";
|
||||||
|
|
||||||
export interface ReviewPanelProps {
|
export interface ReviewPanelProps {
|
||||||
messages: MessageRecord[];
|
messages: MessageRecord[];
|
||||||
@@ -8,30 +9,21 @@ export interface ReviewPanelProps {
|
|||||||
|
|
||||||
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
|
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
|
||||||
const reviewItems = messages.filter(
|
const reviewItems = messages.filter(
|
||||||
(m) =>
|
(message) =>
|
||||||
m.ai_status === "warn" ||
|
message.ai_status === "warn" ||
|
||||||
m.ai_status === "flagged" ||
|
message.ai_status === "flagged" ||
|
||||||
m.ai_status === "error",
|
message.ai_status === "error",
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="review-panel">
|
<Card>
|
||||||
<div className="review-header">
|
<CardHeader>
|
||||||
<h2>Needs Review</h2>
|
<CardTitle>Needs Review</CardTitle>
|
||||||
<span className="review-count">{reviewItems.length}</span>
|
<CardDescription>{reviewItems.length} captured messages require attention.</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{reviewItems.length === 0 ? (
|
<MessageFeed messages={reviewItems} onReanalyze={onReanalyze} emptyText="No warned, flagged, or errored messages." />
|
||||||
<div className="empty-state">
|
</CardContent>
|
||||||
<p>No items to review</p>
|
</Card>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="review-list">
|
|
||||||
{reviewItems.map((msg) => (
|
|
||||||
<MessageCard key={msg.id} message={msg} onReanalyze={onReanalyze} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
type BadgeVariant = "default" | "secondary" | "destructive" | "outline" | "success" | "warning";
|
||||||
|
|
||||||
|
const variants: Record<BadgeVariant, string> = {
|
||||||
|
default: "border-transparent bg-primary text-primary-foreground",
|
||||||
|
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||||
|
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
||||||
|
outline: "text-foreground",
|
||||||
|
success: "border-transparent bg-emerald-500/15 text-emerald-300",
|
||||||
|
warning: "border-transparent bg-amber-500/15 text-amber-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ className, variant = "default", ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||||
|
variants[variant],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
type ButtonVariant = "default" | "secondary" | "destructive" | "outline" | "ghost";
|
||||||
|
type ButtonSize = "default" | "sm" | "lg" | "icon";
|
||||||
|
|
||||||
|
const variants: Record<ButtonVariant, string> = {
|
||||||
|
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes: Record<ButtonSize, string> = {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
asChild?: boolean;
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
variants[variant],
|
||||||
|
sizes[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("rounded-2xl border border-border bg-card text-card-foreground shadow-sm", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return <h3 className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("flex items-center p-6 pt-0", className)} {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export function Input({ className, type, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export function ScrollArea({ className, children, ...props }: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root className={cn("relative overflow-hidden", className)} {...props}>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({ className, orientation = "vertical", ...props }: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
options: SelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ className, options, placeholder, ...props }: SelectProps) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{placeholder ? <option value="">{placeholder}</option> : null}
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
export function TabsList({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
className={cn("inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsTrigger({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsContent({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
className={cn("mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ActiveSpeaker } from "../../types/voice";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
|
||||||
|
interface ActiveSpeakersProps {
|
||||||
|
speakers: ActiveSpeaker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Active Speakers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{speakers.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||||
|
No active speakers.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{speakers.map((speaker, index) => (
|
||||||
|
<div key={speaker.userId || speaker.id || index} className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3">
|
||||||
|
<img src={speaker.avatar} alt="" className="h-10 w-10 rounded-full object-cover" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">{speaker.username}</div>
|
||||||
|
<div className="text-xs text-emerald-300">Speaking</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
interface AudioVisualizerProps {
|
||||||
|
levels: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||||
|
const bars = levels.length ? levels : Array.from({ length: 32 }, () => 0.04);
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 items-end gap-1 rounded-2xl border border-border bg-background/60 p-4">
|
||||||
|
{bars.map((level, index) => (
|
||||||
|
<div
|
||||||
|
key={`${index}-${level}`}
|
||||||
|
className="flex-1 rounded-full bg-gradient-to-t from-primary/50 to-cyan-300 transition-all duration-150"
|
||||||
|
style={{ height: `${Math.max(6, Math.min(100, level * 100))}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Channel, Guild, VoiceStatus } from "../../types/voice";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Select } from "../ui/select";
|
||||||
|
|
||||||
|
interface VoiceControlProps {
|
||||||
|
guilds: Guild[];
|
||||||
|
channels: Channel[];
|
||||||
|
selectedGuild: string;
|
||||||
|
selectedChannel: string;
|
||||||
|
status: VoiceStatus;
|
||||||
|
loading: boolean;
|
||||||
|
onGuildChange: (guildId: string) => void;
|
||||||
|
onChannelChange: (channelId: string) => void;
|
||||||
|
onJoin: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
onListenToggle: () => void;
|
||||||
|
onStreamingToggle: () => void;
|
||||||
|
isListening: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoiceControl({
|
||||||
|
guilds,
|
||||||
|
channels,
|
||||||
|
selectedGuild,
|
||||||
|
selectedChannel,
|
||||||
|
status,
|
||||||
|
loading,
|
||||||
|
onGuildChange,
|
||||||
|
onChannelChange,
|
||||||
|
onJoin,
|
||||||
|
onDisconnect,
|
||||||
|
onListenToggle,
|
||||||
|
onStreamingToggle,
|
||||||
|
isListening,
|
||||||
|
isStreaming,
|
||||||
|
}: VoiceControlProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Voice Bridge</CardTitle>
|
||||||
|
<CardDescription>Join a Discord voice channel and monitor audio in real time.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Guild</label>
|
||||||
|
<Select
|
||||||
|
value={selectedGuild}
|
||||||
|
onChange={(event) => onGuildChange(event.target.value)}
|
||||||
|
placeholder="Select guild"
|
||||||
|
options={guilds.map((guild) => ({ value: guild.id, label: guild.name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Voice Channel</label>
|
||||||
|
<Select
|
||||||
|
value={selectedChannel}
|
||||||
|
onChange={(event) => onChannelChange(event.target.value)}
|
||||||
|
placeholder="Select voice channel"
|
||||||
|
options={channels.map((channel) => ({ value: channel.id, label: channel.name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button disabled={!selectedGuild || !selectedChannel || loading} onClick={onJoin}>
|
||||||
|
{status.connected ? "Reconnect" : "Join Voice"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" disabled={!status.connected || loading} onClick={onDisconnect}>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
<Button variant={isListening ? "secondary" : "outline"} onClick={onListenToggle}>
|
||||||
|
{isListening ? "Stop Listening" : "Listen Live"}
|
||||||
|
</Button>
|
||||||
|
<Button variant={isStreaming ? "destructive" : "default"} onClick={onStreamingToggle}>
|
||||||
|
{isStreaming ? "Stop Transmitting" : "Start Transmitting"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ActiveSpeaker, Channel, Guild, VoiceStatus } from "../../types/voice";
|
||||||
|
import { AudioVisualizer } from "./AudioVisualizer";
|
||||||
|
import { ActiveSpeakers } from "./ActiveSpeakers";
|
||||||
|
import { VoiceControl } from "./VoiceControl";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
|
||||||
|
interface VoicePanelProps {
|
||||||
|
guilds: Guild[];
|
||||||
|
channels: Channel[];
|
||||||
|
selectedGuild: string;
|
||||||
|
selectedChannel: string;
|
||||||
|
status: VoiceStatus;
|
||||||
|
loading: boolean;
|
||||||
|
activeSpeakers: ActiveSpeaker[];
|
||||||
|
levels: number[];
|
||||||
|
isListening: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
onGuildChange: (guildId: string) => void;
|
||||||
|
onChannelChange: (channelId: string) => void;
|
||||||
|
onJoin: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
onListenToggle: () => void;
|
||||||
|
onStreamingToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoicePanel(props: VoicePanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<VoiceControl {...props} />
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1fr_360px]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Live Audio Visualizer</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AudioVisualizer levels={props.levels} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<ActiveSpeakers speakers={props.activeSpeakers} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { MessageRecord } from "../types/messages";
|
||||||
|
import type { MediaState } from "../types/media";
|
||||||
|
import type { UIState } from "../types/ui";
|
||||||
|
import type { ActiveSpeaker } from "../types/voice";
|
||||||
|
|
||||||
|
export type WebSocketStatus = "connecting" | "connected" | "disconnected" | "error";
|
||||||
|
|
||||||
|
export interface DashboardSocketHandlers {
|
||||||
|
onUIState?: (state: UIState) => void;
|
||||||
|
onUserState?: (users: ActiveSpeaker[]) => void;
|
||||||
|
onMessageCreated?: (message: MessageRecord) => void;
|
||||||
|
onMessageUpdated?: (message: Partial<MessageRecord> & { id: string }) => void;
|
||||||
|
onMessageDeleted?: (message: { id: string }) => void;
|
||||||
|
onMessageAnalyzed?: (message: MessageRecord) => void;
|
||||||
|
onAttachmentUploaded?: () => void;
|
||||||
|
onMediaState?: (state: MediaState) => void;
|
||||||
|
onPcm?: (data: ArrayBuffer) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboardSocket(handlers: DashboardSocketHandlers) {
|
||||||
|
const [status, setStatus] = useState<WebSocketStatus>("connecting");
|
||||||
|
const handlersRef = useRef(handlers);
|
||||||
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
handlersRef.current = handlers;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let closed = false;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const socket = new WebSocket(`${protocol}//${location.host}/ws`);
|
||||||
|
socket.binaryType = "arraybuffer";
|
||||||
|
socketRef.current = socket;
|
||||||
|
setStatus("connecting");
|
||||||
|
|
||||||
|
socket.addEventListener("open", () => setStatus("connected"));
|
||||||
|
socket.addEventListener("error", () => setStatus("error"));
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
setStatus("disconnected");
|
||||||
|
if (!closed) reconnectTimer = window.setTimeout(connect, 2500);
|
||||||
|
});
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
handlersRef.current.onPcm?.(event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof event.data !== "string") return;
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
switch (message.type) {
|
||||||
|
case "ui_state":
|
||||||
|
handlersRef.current.onUIState?.(message.state);
|
||||||
|
break;
|
||||||
|
case "user_state":
|
||||||
|
handlersRef.current.onUserState?.(message.users || []);
|
||||||
|
break;
|
||||||
|
case "message_created":
|
||||||
|
handlersRef.current.onMessageCreated?.(message.data);
|
||||||
|
break;
|
||||||
|
case "message_updated":
|
||||||
|
handlersRef.current.onMessageUpdated?.(message.data);
|
||||||
|
break;
|
||||||
|
case "message_deleted":
|
||||||
|
handlersRef.current.onMessageDeleted?.(message.data);
|
||||||
|
break;
|
||||||
|
case "message_analyzed":
|
||||||
|
handlersRef.current.onMessageAnalyzed?.(message.data);
|
||||||
|
break;
|
||||||
|
case "attachment_uploaded":
|
||||||
|
handlersRef.current.onAttachmentUploaded?.();
|
||||||
|
break;
|
||||||
|
case "media_state":
|
||||||
|
handlersRef.current.onMediaState?.(message.state);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed socket messages
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
if (reconnectTimer) window.clearTimeout(reconnectTimer);
|
||||||
|
socketRef.current?.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { status, socketRef };
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
getMediaStatus,
|
||||||
|
queueMedia,
|
||||||
|
setMediaVolume,
|
||||||
|
skipMedia,
|
||||||
|
stopMedia,
|
||||||
|
} from "../api/media";
|
||||||
|
import type { MediaMode, MediaState } from "../types/media";
|
||||||
|
|
||||||
|
const emptyMediaState: MediaState = {
|
||||||
|
playing: false,
|
||||||
|
musicVolume: 1,
|
||||||
|
current: null,
|
||||||
|
queue: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMediaControl() {
|
||||||
|
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshMedia = useCallback(async () => {
|
||||||
|
const state = await getMediaStatus();
|
||||||
|
setMediaState(state);
|
||||||
|
return state;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enqueue = useCallback(async (source: string, mode: MediaMode) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const state = await queueMedia(source, mode);
|
||||||
|
setMediaState(state);
|
||||||
|
return state;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const skip = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const state = await skipMedia();
|
||||||
|
setMediaState(state);
|
||||||
|
return state;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stop = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const state = await stopMedia();
|
||||||
|
setMediaState(state);
|
||||||
|
return state;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setVolume = useCallback(async (volume: number) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const state = await setMediaVolume(volume);
|
||||||
|
setMediaState(state);
|
||||||
|
return state;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshMedia().catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||||
|
}, [refreshMedia]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaState,
|
||||||
|
setMediaState,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refreshMedia,
|
||||||
|
enqueue,
|
||||||
|
skip,
|
||||||
|
stop,
|
||||||
|
setVolume,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { listMessages, reanalyzeMessage } from "../api/messages";
|
||||||
|
import type { MessageRecord } from "../types/messages";
|
||||||
|
|
||||||
|
export function mergeMessages(current: MessageRecord[], incoming: MessageRecord[]): MessageRecord[] {
|
||||||
|
const byId = new Map(current.map((message) => [message.id, message]));
|
||||||
|
for (const message of incoming) {
|
||||||
|
byId.set(message.id, { ...byId.get(message.id), ...message });
|
||||||
|
}
|
||||||
|
return Array.from(byId.values())
|
||||||
|
.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
|
||||||
|
.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMessages() {
|
||||||
|
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchMessages = useCallback(async (channelId?: string) => {
|
||||||
|
if (!channelId) {
|
||||||
|
setMessages([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ limit: "80" });
|
||||||
|
params.set("channel", channelId);
|
||||||
|
const result = await listMessages(params);
|
||||||
|
setMessages(result.data);
|
||||||
|
return result.data;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reanalyze = useCallback(async (id: string) => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === id
|
||||||
|
? { ...message, ai_status: "pending", ai_error: null, ai_analysis: null }
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await reanalyzeMessage(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMessages().catch(() => undefined);
|
||||||
|
}, [fetchMessages]);
|
||||||
|
|
||||||
|
return { messages, setMessages, loading, error, fetchMessages, reanalyze };
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { getUIState, updateUIState } from "../api/uiState";
|
||||||
|
import type { UIState } from "../types/ui";
|
||||||
|
|
||||||
|
export function useUIState() {
|
||||||
|
const [uiState, setUIState] = useState<UIState>({ activeTab: "voice" });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getUIState()
|
||||||
|
.then((state) => {
|
||||||
|
if (!cancelled) setUIState({ activeTab: "voice", ...state });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const patchUIState = useCallback(async (patch: Partial<UIState>) => {
|
||||||
|
setUIState((prev) => ({ ...prev, ...patch }));
|
||||||
|
const next = await updateUIState(patch);
|
||||||
|
setUIState((prev) => ({ ...prev, ...next }));
|
||||||
|
return next;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { uiState, setUIState, patchUIState, loading, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
connectVoice,
|
||||||
|
disconnectVoice,
|
||||||
|
getGuilds,
|
||||||
|
getTextChannels,
|
||||||
|
getVoiceChannels,
|
||||||
|
getVoiceStatus,
|
||||||
|
} from "../api/voice";
|
||||||
|
import type { Channel, Guild, VoiceStatus } from "../types/voice";
|
||||||
|
|
||||||
|
export function useVoiceControl() {
|
||||||
|
const [guilds, setGuilds] = useState<Guild[]>([]);
|
||||||
|
const [voiceChannels, setVoiceChannels] = useState<Channel[]>([]);
|
||||||
|
const [textChannels, setTextChannels] = useState<Channel[]>([]);
|
||||||
|
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>({ connected: false });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshGuilds = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
const nextGuilds = await getGuilds();
|
||||||
|
setGuilds(nextGuilds);
|
||||||
|
return nextGuilds;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshVoiceStatus = useCallback(async () => {
|
||||||
|
const status = await getVoiceStatus();
|
||||||
|
setVoiceStatus(status);
|
||||||
|
return status;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadVoiceChannels = useCallback(async (guildId: string) => {
|
||||||
|
if (!guildId) {
|
||||||
|
setVoiceChannels([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const channels = await getVoiceChannels(guildId);
|
||||||
|
setVoiceChannels(channels);
|
||||||
|
return channels;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTextTargets = useCallback(async (guildId: string) => {
|
||||||
|
if (!guildId) {
|
||||||
|
setTextChannels([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const channels = await getTextChannels(guildId);
|
||||||
|
setTextChannels(channels);
|
||||||
|
return channels;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const joinVoice = useCallback(async (guildId: string, channelId: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const status = await connectVoice(guildId, channelId);
|
||||||
|
setVoiceStatus(status);
|
||||||
|
return status;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const leaveVoice = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const status = await disconnectVoice();
|
||||||
|
setVoiceStatus(status);
|
||||||
|
return status;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshGuilds().catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||||
|
refreshVoiceStatus().catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||||
|
}, [refreshGuilds, refreshVoiceStatus]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
guilds,
|
||||||
|
voiceChannels,
|
||||||
|
textChannels,
|
||||||
|
voiceStatus,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refreshGuilds,
|
||||||
|
refreshVoiceStatus,
|
||||||
|
loadVoiceChannels,
|
||||||
|
loadTextTargets,
|
||||||
|
joinVoice,
|
||||||
|
leaveVoice,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user