Compare commits

..

4 Commits

Author SHA1 Message Date
MythEclipse
82025a19b2 feat: migrate and redesign dashboard to modern React
- Full rewrite of legacy vanilla JS UI into React SPA
- Implement modern design system using Tailwind CSS and shadcn/ui primitives
- Create typed API modules and hooks for voice, media, and moderation
- Add new features: separated Music and Screen Share panels, Image Grid
- Implement unified WebSocket hook for real-time state and PCM audio
- Improve visualizer with smooth CSS transitions and live state sync
- Add __dirname polyfill for ES module compatibility
- Ensure responsive layout for mobile and desktop

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 19:17:34 +07:00
MythEclipse
3c7d722973 feat: separate music and screen share UI in media player
- Add mode selector buttons (Music / Screen Share)
- Create separate input panels for music and screen share
- Add screenSourceInput and screen share queue/skip/stop buttons
- Update queueMedia to include mode parameter (music/screen)
- Add queueScreen function for screen share mode
- Toggle between panels based on selected mode

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 18:22:09 +07:00
MythEclipse
70931576dc fix: add __dirname polyfill for ES modules
- Import fileURLToPath from node:url
- Define __dirname using import.meta.url for ES module compatibility
- Fixes ReferenceError when serving static files in production

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 18:18:39 +07:00
MythEclipse
8e1f5adaa4 feat: implement session-level recording with self/bot audio filtering
- Add RecordingSession tracking for guild-level audio mixing
- Filter out self user and bot users before subscribing to audio streams
- Register segments with session metadata for post-processing
- Finalize recording sessions on connection destroy or explicit stop
- Update test mocks to properly simulate stream chain and constructors
- Remove mock-crc import (no longer needed with current dependencies)
- Add 'type: module' to package.json for ES module support

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 18:15:18 +07:00
53 changed files with 2882 additions and 1211 deletions

20
components.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,124 +1,169 @@
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 { 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 audioContextRef = useRef<AudioContext | null>(null);
const userTimelinesRef = useRef(new Map<number, number>());
const activeTab = uiState.activeTab || "voice";
const selectedVoiceGuild = uiState.selectedVoiceGuild || uiState.selectedGuild || "";
const selectedVoiceChannel = uiState.selectedVoiceChannel || "";
const selectedTextGuild = uiState.selectedTextGuild || uiState.selectedGuild || "";
const selectedTextChannel = uiState.selectedTextChannel || "";
const handleIncomingPcm = useCallback((data: ArrayBuffer) => {
const headerView = new DataView(data, 0, 4);
const userIdHash = headerView.getInt32(0, true);
const audioData = data.slice(4);
const int16Array = new Int16Array(audioData);
let sum = 0;
for (const sample of int16Array) sum += Math.abs(sample / 32768);
const average = int16Array.length ? sum / int16Array.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)));
const audioContext = audioContextRef.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,
});
useEffect(() => { useEffect(() => {
let cancelled = false; if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
listMessages(new URLSearchParams({ limit: "30" })) useEffect(() => {
.then((result) => { if (selectedTextGuild) voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
if (!cancelled) { }, [selectedTextGuild, voice.loadTextTargets]);
setMessages(mergeMessages([], result.data));
}
})
.catch((err) => {
if (!cancelled) {
console.error("Failed to load messages:", err);
}
});
const ws = connectDashboardSocket((event: DashboardEvent) => { useEffect(() => {
switch (event.type) { messages.fetchMessages(selectedTextChannel).catch(() => undefined);
case "message_created": }, [selectedTextChannel, messages.fetchMessages]);
setMessages((prev) => mergeMessages(prev, [event.data]));
break;
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;
}
});
wsRef.current = ws; const toggleListening = useCallback(async () => {
if (isListening) {
ws.addEventListener("open", () => setWsStatus("connected")); await audioContextRef.current?.suspend();
ws.addEventListener("close", () => setWsStatus("disconnected")); userTimelinesRef.current.clear();
ws.addEventListener("error", () => setWsStatus("error")); setIsListening(false);
await patchUIState({ isListening: false });
return () => { return;
cancelled = true;
ws.close();
wsRef.current = null;
};
}, []);
const handleReanalyze = async (id: string) => {
// Optimistic update
setMessages((prev) =>
prev.map((m) =>
m.id === id
? { ...m, ai_status: "pending" as const, ai_error: null, ai_analysis: null }
: m,
),
);
try {
await reanalyzeMessage(id);
} catch (err) {
console.error("Reanalyze failed:", err);
// Revert optimistic update on failure
setMessages((prev) =>
prev.map((m) =>
m.id === id ? { ...m, ai_status: "error" as const, ai_error: "Reanalyze failed" } : m,
),
);
} }
}; const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
audioContextRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
await audioContextRef.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}
onTabChange={(tab) => patchUIState({ activeTab: tab })}
>
<div className="md:hidden">
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
<div className="mb-4 grid grid-cols-4 gap-2 rounded-2xl bg-muted p-1">
{tabs.map((tab) => (
<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 })}>
{tab}
</button>
))}
</div>
</Tabs>
</div> </div>
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
<div className="main"> <TabsContent value="voice">
<div className="header"> <VoicePanel
<h1>Discord Moderation Dashboard</h1> guilds={voice.guilds}
<span className="ws-status" data-status={wsStatus}> channels={voice.voiceChannels}
{wsStatus} selectedGuild={selectedVoiceGuild}
</span> selectedChannel={selectedVoiceChannel}
</div> status={voice.voiceStatus}
loading={voice.loading}
<div className="content"> activeSpeakers={activeSpeakers}
<MessageFeed messages={messages} onReanalyze={handleReanalyze} /> levels={levels}
</div> isListening={isListening}
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
<ReviewPanel messages={messages} onReanalyze={handleReanalyze} /> onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
</div> onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
</div> onDisconnect={() => voice.leaveVoice()}
onListenToggle={toggleListening}
/>
</TabsContent>
<TabsContent value="media">
<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}
/>
</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>
); );
} }

View File

@@ -49,7 +49,7 @@ 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 res = await fetch(path, { const res = await fetch(path, {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
...init, ...init,

21
frontend/src/api/media.ts Normal file
View File

@@ -0,0 +1,21 @@
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' });
}

View File

@@ -0,0 +1,3 @@
import { listMessages, listReview, reanalyzeMessage } from "./client";
export { listMessages, listReview, reanalyzeMessage };

View File

@@ -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),
});
}

33
frontend/src/api/voice.ts Normal file
View File

@@ -0,0 +1,33 @@
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 getThreads(guildId: string): Promise<Channel[]> {
return request<Channel[]>(`/api/guilds/${guildId}/threads`);
}
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' });
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,34 @@
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;
}
export function MediaPanel({ state, loading, onQueueMusic, onStartScreen, onSkip, onStop }: 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} 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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,45 @@
import { Music2 } 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 MusicPlayerProps {
loading: boolean;
onQueue: (source: string) => void;
onSkip: () => void;
onStop: () => void;
}
export function MusicPlayer({ loading, onQueue, onSkip, onStop }: MusicPlayerProps) {
const [source, setSource] = useState("");
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="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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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">
<img <div className="flex gap-3">
src={message.avatar_url ?? "/default-avatar.png"} <img
alt={message.username} src={message.avatar_url ?? "/default-avatar.png"}
className="message-card-avatar" alt=""
width={32} className="h-10 w-10 rounded-full object-cover"
height={32} />
/> <div className="min-w-0 flex-1 space-y-3">
<div className="message-card-body"> <div className="flex flex-wrap items-center gap-2">
<div className="message-card-meta"> <span className="font-medium">{message.username || message.user_id}</span>
<span className="message-card-username">{message.username}</span> <span className="text-xs text-muted-foreground">{new Date(message.created_at).toLocaleString()}</span>
<span className="message-card-time"> {message.edited_at ? <Badge variant="outline">edited</Badge> : null}
{new Date(message.created_at).toLocaleString()} {message.deleted_at ? <Badge variant="destructive">deleted</Badge> : null}
</span> <Badge variant={aiVariant(aiStatus)}>{aiStatus}</Badge>
{message.type === "edited" && ( </div>
<span className="badge badge-edited">edited</span> <p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
)} {displayContent || "(empty message)"}
{message.type === "deleted" && ( </p>
<span className="badge badge-deleted">deleted</span> {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}
<span <Button size="sm" variant="outline" onClick={() => onReanalyze(message.id)} disabled={aiStatus === "pending"}>
className="badge badge-ai" <RotateCw className="h-3.5 w-3.5" />
style={{ backgroundColor: statusColor }} Re-analyze
title={`AI: ${aiStatus}`} </Button>
>
{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>
</div> </div>
</div> </article>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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}
/>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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} />;
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,76 @@
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;
isListening: boolean;
}
export function VoiceControl({
guilds,
channels,
selectedGuild,
selectedChannel,
status,
loading,
onGuildChange,
onChannelChange,
onJoin,
onDisconnect,
onListenToggle,
isListening,
}: 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>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,41 @@
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;
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
onJoin: () => void;
onDisconnect: () => void;
onListenToggle: () => 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>
);
}

View File

@@ -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 };
}

View File

@@ -0,0 +1,63 @@
import { useCallback, useEffect, useState } from "react";
import { getMediaStatus, queueMedia, skipMedia, stopMedia } from "../api/media";
import type { MediaMode, MediaState } from "../types/media";
const emptyMediaState: MediaState = { playing: false, 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);
}
}, []);
useEffect(() => {
refreshMedia().catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [refreshMedia]);
return { mediaState, setMediaState, loading, error, refreshMedia, enqueue, skip, stop };
}

View File

@@ -0,0 +1,54 @@
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) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ limit: "80" });
if (channelId) 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 };
}

View File

@@ -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 };
}

View File

@@ -0,0 +1,109 @@
import { useCallback, useEffect, useState } from "react";
import {
connectVoice,
disconnectVoice,
getGuilds,
getTextChannels,
getThreads,
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, threads] = await Promise.all([
getTextChannels(guildId),
getThreads(guildId).catch(() => []),
]);
const combined = [...channels, ...threads];
setTextChannels(combined);
return combined;
}, []);
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,
};
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,341 +1,41 @@
* { @tailwind base;
box-sizing: border-box; @tailwind components;
margin: 0; @tailwind utilities;
padding: 0;
}
body { @layer base {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; :root {
background: #1e1e2e; --background: 222 47% 7%;
color: #cdd6f4; --foreground: 210 40% 98%;
height: 100vh; --card: 222 47% 10%;
overflow: hidden; --card-foreground: 210 40% 98%;
} --primary: 199 89% 48%;
--primary-foreground: 210 40% 98%;
--secondary: 217 33% 17%;
--secondary-foreground: 210 40% 98%;
--muted: 217 33% 17%;
--muted-foreground: 215 20% 65%;
--accent: 217 33% 17%;
--accent-foreground: 210 40% 98%;
--destructive: 0 72% 51%;
--destructive-foreground: 210 40% 98%;
--border: 217 33% 20%;
--input: 217 33% 20%;
--ring: 199 89% 48%;
--radius: 0.85rem;
}
#root { * {
height: 100%; @apply border-border;
} }
.app { body {
display: flex; @apply bg-background text-foreground antialiased;
height: 100vh; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
} }
.sidebar { html,
width: 220px; body,
background: #181825; #root {
border-right: 1px solid #313244; min-height: 100%;
display: flex; }
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 16px;
font-size: 14px;
font-weight: 700;
color: #cdd6f4;
border-bottom: 1px solid #313244;
}
.sidebar-placeholder {
padding: 16px;
font-size: 12px;
color: #6c7086;
flex: 1;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid #313244;
background: #181825;
flex-shrink: 0;
}
.header h1 {
font-size: 16px;
font-weight: 600;
color: #cdd6f4;
}
.ws-status {
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
font-weight: 500;
}
.ws-status[data-status="connected"] {
background: #a6e3a1;
color: #1e1e2e;
}
.ws-status[data-status="disconnected"],
.ws-status[data-status="error"] {
background: #f38ba8;
color: #1e1e2e;
}
.ws-status[data-status="connecting"] {
background: #f9e2af;
color: #1e1e2e;
}
.content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.message-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-state {
text-align: center;
color: #6c7086;
padding: 40px;
}
.message-item {
display: flex;
gap: 12px;
padding: 10px 12px;
background: #181825;
border-radius: 6px;
border: 1px solid #313244;
}
.message-item.type-deleted {
opacity: 0.6;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
flex-shrink: 0;
}
.message-body {
flex: 1;
min-width: 0;
}
.message-username {
font-weight: 600;
color: #89b4fa;
margin-right: 8px;
}
.message-time {
font-size: 11px;
color: #6c7086;
}
.message-deleted {
font-size: 11px;
color: #f38ba8;
margin-left: 8px;
font-style: italic;
}
.message-content {
margin-top: 4px;
font-size: 13px;
color: #bac2de;
white-space: pre-wrap;
word-break: break-word;
}
.review-panel {
border-top: 1px solid #313244;
background: #181825;
flex-shrink: 0;
}
/* Message Card */
.message-card {
display: flex;
gap: 12px;
padding: 12px;
background: #181825;
border-radius: 6px;
border: 1px solid #313244;
}
.message-card.type-deleted {
opacity: 0.6;
}
.message-card-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
flex-shrink: 0;
}
.message-card-body {
flex: 1;
min-width: 0;
}
.message-card-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.message-card-username {
font-weight: 600;
color: #89b4fa;
}
.message-card-time {
font-size: 11px;
color: #6c7086;
}
.badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
color: #1e1e2e;
}
.badge-edited {
background: #fab387;
color: #1e1e2e;
}
.badge-deleted {
background: #f38ba8;
color: #1e1e2e;
}
.message-card-content {
margin-top: 6px;
font-size: 13px;
color: #bac2de;
white-space: pre-wrap;
word-break: break-word;
}
.message-card-analysis {
margin-top: 8px;
padding: 8px;
background: #1e1e2e;
border-radius: 4px;
font-size: 12px;
color: #a6e3a1;
white-space: pre-wrap;
}
.message-card-error {
margin-top: 8px;
padding: 8px;
background: #1e1e2e;
border-radius: 4px;
font-size: 12px;
color: #f38ba8;
}
.message-card-actions {
margin-top: 8px;
}
.btn-reanalyze {
padding: 4px 10px;
font-size: 11px;
border: none;
border-radius: 4px;
background: #313244;
color: #cdd6f4;
cursor: pointer;
}
.btn-reanalyze:hover:not(:disabled) {
background: #45475a;
}
.btn-reanalyze:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Message Feed */
.message-feed {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Review Panel */
.review-panel {
border-top: 1px solid #313244;
background: #181825;
flex-shrink: 0;
max-height: 250px;
overflow-y: auto;
}
.review-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid #313244;
}
.review-header h2 {
font-size: 13px;
font-weight: 600;
color: #cdd6f4;
}
.review-count {
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
background: #f38ba8;
color: #1e1e2e;
font-weight: 600;
}
.review-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
}
.review-placeholder {
padding: 16px;
font-size: 12px;
color: #6c7086;
}
.review-panel .empty-state {
padding: 16px;
}
/* Keep existing styles */
.review-placeholder {
padding: 16px;
font-size: 12px;
color: #6c7086;
} }

View File

@@ -0,0 +1,7 @@
export {};
declare global {
interface Window {
webkitAudioContext?: typeof AudioContext;
}
}

View File

@@ -0,0 +1,16 @@
export type MediaMode = "music" | "screen";
export interface MediaItem {
id?: string;
source: string;
title: string;
mode?: MediaMode;
durationMs?: number | null;
thumbnailUrl?: string | null;
}
export interface MediaState {
playing: boolean;
current: MediaItem | null;
queue: MediaItem[];
}

View File

@@ -0,0 +1,29 @@
export type { AIStatus, MessageRecord, PageResult } from "../api/client";
export interface MessageMetadataAttachment {
name: string;
url: string;
size: number;
contentType?: string | null;
}
export interface MessageMetadataEmbed {
title?: string;
description?: string;
url?: string;
image?: string;
thumbnail?: string;
}
export interface MessageMetadataSticker {
name: string;
url: string;
}
export interface MessageMetadata {
attachments?: MessageMetadataAttachment[];
embeds?: MessageMetadataEmbed[];
stickers?: MessageMetadataSticker[];
reference?: { messageId?: string };
channel?: { threadName?: string };
}

12
frontend/src/types/ui.ts Normal file
View File

@@ -0,0 +1,12 @@
export type DashboardTab = "voice" | "media" | "messages" | "review";
export interface UIState {
selectedGuild?: string;
selectedVoiceGuild?: string;
selectedVoiceChannel?: string;
selectedTextGuild?: string;
selectedTextChannel?: string;
activeTab?: DashboardTab;
isListening?: boolean;
isStreaming?: boolean;
}

View File

@@ -0,0 +1,27 @@
export interface Guild {
id: string;
name: string;
icon?: string | null;
}
export interface Channel {
id: string;
name: string;
type?: string;
parentId?: string | null;
}
export interface VoiceStatus {
connected: boolean;
activeGuildId?: string | null;
activeChannelId?: string | null;
activeChannelName?: string | null;
}
export interface ActiveSpeaker {
id?: string;
userId?: string;
username: string;
avatar: string;
speaking: boolean;
}

View File

@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};

View File

@@ -2,6 +2,7 @@
"name": "discord-voice-recorder", "name": "discord-voice-recorder",
"version": "1.0.0", "version": "1.0.0",
"description": "Discord bot that joins a voice channel and records audio", "description": "Discord bot that joins a voice channel and records audio",
"type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"packageManager": "pnpm@10.25.0", "packageManager": "pnpm@10.25.0",
"scripts": { "scripts": {
@@ -25,16 +26,22 @@
"@dank074/discord-video-stream": "workspace:*", "@dank074/discord-video-stream": "workspace:*",
"@discordjs/opus": "^0.10.0", "@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.1", "@discordjs/voice": "^0.19.1",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@snazzah/davey": "^0.1.10", "@snazzah/davey": "^0.1.10",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"better-sqlite3": "^12.10.0", "better-sqlite3": "^12.10.0",
"clsx": "^2.1.1",
"discord.js-selfbot-v13": "workspace:*", "discord.js-selfbot-v13": "workspace:*",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^5.2.1", "express": "^5.2.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"libsodium-wrappers": "^0.8.2", "libsodium-wrappers": "^0.8.2",
"lucide-react": "^1.16.0",
"p-retry": "^8.0.0", "p-retry": "^8.0.0",
"pg": "^8.20.0", "pg": "^8.20.0",
"pino": "^10.3.1", "pino": "^10.3.1",
@@ -45,6 +52,7 @@
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"sodium-native": "^5.1.0", "sodium-native": "^5.1.0",
"tailwind-merge": "^3.6.0",
"vite": "^8.0.13", "vite": "^8.0.13",
"ws": "^8.20.1", "ws": "^8.20.1",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -57,8 +65,11 @@
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"autoprefixer": "^10.5.0",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"postcss": "^8.5.14",
"tailwindcss": "^3.4.17",
"tsx": "^4.22.0", "tsx": "^4.22.0",
"vitest": "latest" "vitest": "latest"
} }

1244
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

5
postcss.config.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
autoprefixer: {},
},
};

View File

@@ -1,417 +0,0 @@
:root {
--bg: #080a0f;
--panel: rgba(18, 22, 32, 0.86);
--panel-strong: #121720;
--line: rgba(255, 255, 255, 0.12);
--text: #edf4ff;
--muted: #91a0b6;
--faint: #536176;
--cyan: #00e5ff;
--green: #39ff88;
--yellow: #ffe45e;
--red: #ff4f6d;
--blue: #6275ff;
--shadow: 0 24px 70px rgba(0, 0, 0, 0.46);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--text);
font-family: Manrope, sans-serif;
background:
radial-gradient(circle at 12% 8%, rgba(0, 229, 255, 0.18), transparent 32rem),
radial-gradient(circle at 88% 0%, rgba(98, 117, 255, 0.2), transparent 28rem),
linear-gradient(145deg, #05060a, var(--bg));
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, black, transparent 84%);
}
button, select { font: inherit; }
.shell {
width: min(1440px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 44px;
}
.hero {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 18px;
align-items: stretch;
margin-bottom: 18px;
}
.brand-card,
.status-card,
.tab-panel,
.content-card {
border: 1px solid var(--line);
background: var(--panel);
backdrop-filter: blur(18px);
box-shadow: var(--shadow);
}
.brand-card {
position: relative;
padding: 28px;
border-radius: 28px;
overflow: hidden;
min-height: 190px;
}
.brand-card::after {
content: "WATCHER";
position: absolute;
right: -14px;
bottom: -20px;
font-family: "Archivo Black", sans-serif;
font-size: clamp(58px, 9vw, 132px);
letter-spacing: -0.08em;
color: rgba(255,255,255,0.035);
line-height: 0.78;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
color: var(--cyan);
font: 700 12px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.18em;
}
.pulse {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--green);
box-shadow: 0 0 20px var(--green);
}
h1 {
margin: 0;
max-width: 840px;
font-family: "Archivo Black", sans-serif;
font-size: clamp(40px, 6vw, 82px);
line-height: 0.88;
letter-spacing: -0.06em;
text-transform: uppercase;
}
.subtitle {
margin: 18px 0 0;
max-width: 720px;
color: var(--muted);
font-size: 15px;
line-height: 1.7;
}
.status-card {
border-radius: 28px;
padding: 24px;
display: grid;
gap: 14px;
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--line);
}
.status-row:last-child { border-bottom: 0; }
.status-label {
color: var(--muted);
font: 700 12px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.status-value {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text);
font: 700 13px/1 "JetBrains Mono", monospace;
}
.dot {
width: 9px;
height: 9px;
border-radius: 99px;
background: var(--faint);
}
.dot.on { background: var(--green); box-shadow: 0 0 16px var(--green); }
.dot.warn { background: var(--yellow); box-shadow: 0 0 16px var(--yellow); }
.tab-panel {
position: sticky;
top: 14px;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border-radius: 24px;
margin-bottom: 18px;
}
.tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tab-btn {
border: 1px solid transparent;
color: var(--muted);
background: transparent;
border-radius: 16px;
padding: 12px 16px;
cursor: pointer;
font-weight: 800;
transition: 160ms ease;
}
.tab-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
.tab-btn.active {
color: #061014;
background: linear-gradient(135deg, var(--cyan), var(--green));
box-shadow: 0 12px 28px rgba(0,229,255,0.18);
}
.filter-row {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
font-size: 13px;
}
select {
min-width: 240px;
color: var(--text);
background: rgba(5,8,14,0.78);
border: 1px solid var(--line);
border-radius: 14px;
padding: 11px 14px;
outline: none;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 18px;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.voice-layout {
display: grid;
grid-template-columns: 380px 1fr;
gap: 18px;
}
.content-card {
border-radius: 28px;
padding: 22px;
}
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.card-title h2 {
margin: 0;
font-family: "Archivo Black", sans-serif;
font-size: 26px;
letter-spacing: -0.04em;
text-transform: uppercase;
}
.mini {
color: var(--faint);
font: 700 11px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.field-group { display: grid; gap: 8px; margin-bottom: 14px; }
.field-group label { color: var(--muted); font-size: 13px; font-weight: 800; }
.button-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.btn {
border: 0;
border-radius: 16px;
padding: 13px 16px;
color: #061014;
cursor: pointer;
font-weight: 900;
transition: transform 140ms ease, filter 140ms ease;
}
.btn:hover { transform: translateY(-1px); filter: brightness(1.08); }
.btn-primary { background: linear-gradient(135deg, var(--cyan), var(--blue)); color: white; }
.btn-success { background: linear-gradient(135deg, var(--green), var(--cyan)); }
.btn-danger { background: linear-gradient(135deg, var(--red), #ff9a6b); color: white; }
.voice-status { color: var(--muted); font-size: 13px; margin-top: 12px; min-height: 20px; }
.visualizer {
display: flex;
align-items: flex-end;
gap: 5px;
height: 130px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 20px;
background: rgba(0,0,0,0.22);
overflow: hidden;
}
.bar {
flex: 1;
min-width: 5px;
border-radius: 999px;
height: 3px;
background: linear-gradient(to top, var(--blue), var(--cyan), var(--green));
box-shadow: 0 0 18px rgba(0,229,255,0.16);
}
.participants {
display: grid;
gap: 10px;
}
.user-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.035);
}
.user-item.speaking { border-color: rgba(57,255,136,0.55); background: rgba(57,255,136,0.08); }
.user-item img { width: 34px; height: 34px; border-radius: 999px; }
.feed {
display: grid;
gap: 12px;
}
.event-card {
display: grid;
gap: 10px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 20px;
background: rgba(255,255,255,0.035);
}
.event-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.author {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 999px;
background: linear-gradient(135deg, var(--blue), var(--cyan));
flex: 0 0 auto;
}
.avatar img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
.name { font-weight: 900; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.time { color: var(--faint); font: 600 11px/1 "JetBrains Mono", monospace; white-space: nowrap; }
.message-text { color: #dbe6f7; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
.sticker-strip, .attachment-strip { display: flex; gap: 10px; flex-wrap: wrap; }
.sticker-img { width: 96px; height: 96px; object-fit: contain; border-radius: 16px; background: rgba(0,0,0,0.22); border: 1px solid var(--line); padding: 8px; }
.attachment-chip { color: var(--cyan); text-decoration: none; border: 1px solid var(--line); border-radius: 14px; padding: 8px 10px; font: 700 12px/1 "JetBrains Mono", monospace; background: rgba(0,229,255,0.06); }
.embed-card { border-left: 4px solid var(--blue); border-radius: 16px; padding: 12px; background: rgba(98,117,255,0.08); display: grid; gap: 8px; }
.embed-title { font-weight: 900; color: var(--text); }
.embed-description { color: var(--muted); line-height: 1.5; white-space: pre-wrap; }
.embed-image { max-width: 360px; width: 100%; border-radius: 14px; border: 1px solid var(--line); }
.badges { display: flex; gap: 8px; flex-wrap: wrap; }
.badge {
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 9px;
color: var(--muted);
font: 700 10px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
}
.badge.edit { color: var(--yellow); border-color: rgba(255,228,94,0.36); }
.badge.delete { color: var(--red); border-color: rgba(255,79,109,0.42); }
.filename { font-size: 13px; font-weight: 900; word-break: break-word; }
.link { color: var(--cyan); text-decoration: none; font-weight: 900; }
.link:hover { text-decoration: underline; }
.empty {
padding: 34px;
text-align: center;
color: var(--faint);
border: 1px dashed var(--line);
border-radius: 22px;
}
.error {
display: none;
margin-bottom: 14px;
padding: 12px 14px;
color: #ffd8df;
border: 1px solid rgba(255,79,109,0.5);
border-radius: 16px;
background: rgba(255,79,109,0.12);
}
@media (max-width: 980px) {
.hero, .voice-layout { grid-template-columns: 1fr; }
.tab-panel { align-items: stretch; flex-direction: column; }
.filter-row { align-items: stretch; flex-direction: column; }
select { width: 100%; min-width: 0; }
}

View File

@@ -1,186 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Discord Moderation Watcher</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/dashboard.css">
</head>
<body>
<main class="shell">
<section class="hero">
<div class="brand-card">
<div class="eyebrow"><span class="pulse"></span> Discord moderation watcher</div>
<h1>Voice. Text. One Watch Floor.</h1>
<p class="subtitle">Static shared-state dashboard with legacy working voice bridge and captured Discord messages.</p>
</div>
<div class="status-card">
<div class="status-row"><span class="status-label">WebSocket</span><span class="status-value"><span id="wsDot" class="dot"></span><span id="wsStatusText">Connecting</span></span></div>
<div class="status-row"><span class="status-label">Voice Link</span><span id="voiceStatusText" class="status-value">Not connected</span></div>
<div class="status-row"><span class="status-label">Active Tab</span><span id="activeTabLabel" class="status-value">Voice</span></div>
</div>
</section>
<nav class="tab-panel">
<div class="tabs"><button class="tab-btn active" data-tab="voice">Voice</button><button class="tab-btn" data-tab="text">Text</button></div>
<div class="filter-row"><span>Text Guild</span><select id="textGuildSelect"><option value="">Select guild</option></select><span>Channel / Thread</span><select id="channelFilter"><option value="">Select channel</option></select></div>
</nav>
<div id="errorBox" class="error"></div>
<section id="voice" class="tab-content active">
<div class="voice-layout">
<div class="content-card">
<div class="card-title"><h2>Voice Control</h2><span class="mini">bridge</span></div>
<div class="field-group"><label for="voiceGuildSelect">Voice Guild</label><select id="voiceGuildSelect"><option value="">Select guild</option></select></div>
<div class="field-group"><label for="channelSelect">Voice Channel</label><select id="channelSelect"><option value="">Select voice channel</option></select></div>
<div class="button-row"><button id="joinVoiceBtn" class="btn btn-success">Join</button><button id="disconnectVoiceBtn" class="btn btn-danger">Disconnect</button></div>
<div class="voice-status" id="voiceStatusNote">Idle</div>
</div>
<div class="content-card">
<div class="card-title"><h2>Live Audio</h2><span class="mini" id="listenStatus">Speaker Off</span></div>
<div style="display:grid;gap:12px;grid-template-columns:1fr 1fr;margin-bottom:14px"><button id="toggleBtn" class="btn btn-primary">Start Transmitting</button><button id="listenBtn" class="btn btn-success">Join Listen Channel</button></div>
<div class="visualizer" id="visualizer"></div>
</div>
<div class="content-card">
<div class="card-title"><h2>Media</h2><span class="mini" id="mediaStatus">Idle</span></div>
<div class="field-group"><label for="mediaSourceInput">Music URL, YouTube, Spotify track, search, or file path</label><input id="mediaSourceInput" type="text" placeholder="YouTube URL, Spotify track, or search terms"></div>
<div class="button-row"><button id="queueMediaBtn" class="btn btn-primary">Queue / Play</button><button id="skipMediaBtn" class="btn btn-success">Skip</button><button id="stopMediaBtn" class="btn btn-danger">Stop</button></div>
<div id="mediaQueueList" class="feed"><div class="empty">No media queued</div></div>
</div>
</div>
<div class="content-card" style="margin-top:18px"><div class="card-title"><h2>Participants</h2><span class="mini">speaking now</span></div><div id="userList" class="participants"></div></div>
</section>
<section id="text" class="tab-content"><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;height:100%"><div class="content-card"><div class="card-title"><h2>All Messages</h2><span class="mini">all captures</span></div><div id="textList" class="feed"><div class="empty">Select channel to view text captures</div></div></div><div class="content-card"><div class="card-title"><h2>Needs Review</h2><span class="mini">warn + flagged</span></div><div id="reviewList" class="feed"><div class="empty">No warned or flagged messages</div></div></div></div></section>
</main>
<script>
const state = {
socket: null,
selectedVoiceGuild: '',
selectedVoiceChannel: '',
selectedTextGuild: '',
selectedTextChannel: '',
activeTab: 'voice',
text: [],
isStreaming: false,
isListening: false,
localStreaming: false,
localListening: false,
mediaAutoListening: false,
audioContextTransmit: null,
audioContextListen: null,
processor: null,
userTimelines: new Map(),
applyingServerState: false,
media: { playing: false, current: null, queue: [] },
};
const SAMPLE_RATE = 24000;
const CHANNELS = 1;
const el = {
wsDot: document.getElementById('wsDot'), wsStatusText: document.getElementById('wsStatusText'), activeTabLabel: document.getElementById('activeTabLabel'), errorBox: document.getElementById('errorBox'), voiceGuildSelect: document.getElementById('voiceGuildSelect'), textGuildSelect: document.getElementById('textGuildSelect'), channelSelect: document.getElementById('channelSelect'), channelFilter: document.getElementById('channelFilter'), joinVoiceBtn: document.getElementById('joinVoiceBtn'), disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), voiceStatusText: document.getElementById('voiceStatusText'), voiceStatusNote: document.getElementById('voiceStatusNote'), toggleBtn: document.getElementById('toggleBtn'), listenBtn: document.getElementById('listenBtn'), listenStatus: document.getElementById('listenStatus'), visualizer: document.getElementById('visualizer'), userList: document.getElementById('userList'), textList: document.getElementById('textList'), reviewList: document.getElementById('reviewList'), mediaSourceInput: document.getElementById('mediaSourceInput'), mediaStatus: document.getElementById('mediaStatus'), queueMediaBtn: document.getElementById('queueMediaBtn'), skipMediaBtn: document.getElementById('skipMediaBtn'), stopMediaBtn: document.getElementById('stopMediaBtn'), mediaQueueList: document.getElementById('mediaQueueList')
};
for (let i = 0; i < 32; i++) { const bar = document.createElement('div'); bar.className = 'bar'; el.visualizer.appendChild(bar); }
const bars = [...document.querySelectorAll('.bar')];
async function apiRequest(url, options = {}) { const response = await fetch(url, { headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options }); if (!response.ok) { const error = await response.json().catch(() => ({ message: response.statusText })); throw new Error(error.message || response.statusText); } return response.json(); }
function showError(message) { el.errorBox.textContent = message; el.errorBox.style.display = 'block'; setTimeout(() => { el.errorBox.style.display = 'none'; }, 4500); }
async function postUIState(patch) { const next = await apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify(patch) }); await applyServerState(next); return next; }
function renderOptions(select, items, placeholder) { select.replaceChildren(); const first = document.createElement('option'); first.value = ''; first.textContent = placeholder; select.appendChild(first); for (const item of items) { const option = document.createElement('option'); option.value = item.id; option.textContent = item.name; select.appendChild(option); } }
function appendOptions(select, items) { const existing = new Set([...select.options].map((option) => option.value)); for (const item of items) { if (existing.has(item.id)) continue; const option = document.createElement('option'); option.value = item.id; option.textContent = item.name; select.appendChild(option); } }
function appendEmpty(parent, message) { const empty = document.createElement('div'); empty.className = 'empty'; empty.textContent = message; parent.appendChild(empty); }
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.voiceGuildSelect, guilds, 'Select guild'); renderOptions(el.textGuildSelect, guilds, 'Select guild'); if (state.selectedVoiceGuild) { el.voiceGuildSelect.value = state.selectedVoiceGuild; await loadVoiceChannels(state.selectedVoiceGuild); } if (state.selectedTextGuild) { el.textGuildSelect.value = state.selectedTextGuild; await loadTextChannels(state.selectedTextGuild); } }
async function loadVoiceChannels(guildId) { if (!guildId) return renderOptions(el.channelSelect, [], 'Select voice channel'); const voiceChannels = await apiRequest(`/api/guilds/${guildId}/voice-channels`); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; }
async function loadTextChannels(guildId) { if (!guildId) return renderOptions(el.channelFilter, [], 'Select channel'); const watchChannels = await apiRequest(`/api/guilds/${guildId}/channels`); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; apiRequest(`/api/guilds/${guildId}/threads`).then((threads) => { appendOptions(el.channelFilter, threads); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; }).catch((error) => showError(`Thread discovery failed: ${error.message}`)); }
async function refreshStatus() { try { const status = await apiRequest('/api/status'); el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; } catch (error) { showError(error.message); } }
async function connectVoice() { const guildId = el.voiceGuildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: channelId }); const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; }
async function disconnectVoice() { await apiRequest('/api/disconnect', { method: 'POST' }); await refreshStatus(); }
function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; state.socket = new WebSocket(`${protocol}//${location.host}/ws`); state.socket.binaryType = 'arraybuffer'; state.socket.onopen = () => { el.wsDot.classList.add('on'); el.wsStatusText.textContent = 'Connected'; }; state.socket.onclose = () => { el.wsDot.classList.remove('on'); el.wsStatusText.textContent = 'Reconnecting'; setTimeout(connectWebSocket, 2500); }; state.socket.onerror = () => { el.wsDot.classList.remove('on'); el.wsDot.classList.add('warn'); el.wsStatusText.textContent = 'Socket error'; }; state.socket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { handleIncomingPCM(event.data); return; } try { handleJsonEvent(event.data); } catch {} }; }
function handleJsonEvent(raw) { const message = JSON.parse(raw); if (message.type === 'ui_state') return applyServerState(message.state); if (message.type === 'user_state') return renderUsers(message.users || []); if (message.type === 'message_created') { state.text.unshift(message.data); renderText(); } if (message.type === 'message_updated') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { edited_content: message.data.edited_content, edited_at: message.data.edited_at, type: 'edited' }); renderText(); } if (message.type === 'message_deleted') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { deleted_at: message.data.deleted_at, type: 'deleted' }); renderText(); } if (message.type === 'attachment_uploaded') fetchText(); if (message.type === 'message_analyzed') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, message.data); renderText(); } if (message.type === 'media_state') { state.media = message.state; reconcileDynamicAudio().catch((error) => showError(error.message)); renderMedia(); } }
async function applyServerState(next) {
if (!next || state.applyingServerState) return;
state.applyingServerState = true;
const nextVoiceGuild = next.selectedVoiceGuild || next.selectedGuild || '';
const nextTextGuild = next.selectedTextGuild || next.selectedGuild || '';
const voiceGuildChanged = nextVoiceGuild !== state.selectedVoiceGuild;
const textGuildChanged = nextTextGuild !== state.selectedTextGuild;
const textChanged = next.selectedTextChannel !== state.selectedTextChannel;
state.selectedVoiceGuild = nextVoiceGuild;
state.selectedVoiceChannel = next.selectedVoiceChannel || '';
state.selectedTextGuild = nextTextGuild;
state.selectedTextChannel = next.selectedTextChannel || '';
state.activeTab = next.activeTab || 'voice';
state.isListening = !!next.isListening;
state.isStreaming = !!next.isStreaming;
el.voiceGuildSelect.value = state.selectedVoiceGuild;
el.textGuildSelect.value = state.selectedTextGuild;
if (voiceGuildChanged) await loadVoiceChannels(state.selectedVoiceGuild);
if (textGuildChanged) await loadTextChannels(state.selectedTextGuild);
el.channelSelect.value = state.selectedVoiceChannel;
el.channelFilter.value = state.selectedTextChannel;
applyActiveTab(state.activeTab);
if ((textChanged || textGuildChanged) && state.selectedTextChannel && state.selectedTextGuild) {
apiRequest('/api/backlog-sync', {
method: 'POST',
body: JSON.stringify({ guildId: state.selectedTextGuild, channelId: state.selectedTextChannel }),
}).catch((error) => showError(`Backlog sync failed: ${error.message}`));
}
if (textChanged || textGuildChanged || state.activeTab === 'text') {
fetchText().catch((error) => showError(error.message));
}
await reconcileDynamicAudio();
state.applyingServerState = false;
}
function applyActiveTab(tab) { document.querySelectorAll('.tab-btn').forEach((item) => item.classList.toggle('active', item.dataset.tab === tab)); document.querySelectorAll('.tab-content').forEach((item) => item.classList.toggle('active', item.id === tab)); el.activeTabLabel.textContent = tab === 'text' ? 'Text' : 'Voice'; }
async function reconcileDynamicAudio() { await reconcileStreamingState(); await reconcileListenState(); }
async function reconcileListenState() { const shouldListen = state.isListening || !!state.media.current; if (shouldListen && !state.localListening) { try { await startListeningLocal(!!state.media.current && !state.isListening); } catch (error) { showError(`Speaker error: ${error.message}`); state.isListening = false; state.mediaAutoListening = false; stopListeningLocal(); apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify({ isListening: false }) }).catch((postError) => showError(postError.message)); } } else if (!shouldListen && state.localListening) { stopListeningLocal(); } else if (state.localListening) { renderListenStatus(); } }
async function reconcileStreamingState() { if (state.media.current && state.isStreaming) { state.isStreaming = false; apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify({ isStreaming: false }) }).catch((postError) => showError(postError.message)); } if (state.isStreaming && !state.localStreaming) { try { await startStreamingLocal(); } catch (error) { showError(`Microphone error: ${error.message}`); state.isStreaming = false; stopStreamingLocal(); apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify({ isStreaming: false }) }).catch((postError) => showError(postError.message)); } } else if (!state.isStreaming && state.localStreaming) { stopStreamingLocal(); } }
function renderUsers(users) { el.userList.replaceChildren(); if (users.length === 0) return appendEmpty(el.userList, 'No active speakers'); for (const user of users) { const row = document.createElement('div'); row.className = `user-item${user.speaking ? ' speaking' : ''}`; const img = document.createElement('img'); img.src = user.avatar || ''; img.alt = ''; const name = document.createElement('span'); name.textContent = user.username; row.append(img, name); el.userList.appendChild(row); } }
async function fetchText() { if (!state.selectedTextChannel) return renderText(); const result = await apiRequest(`/api/messages?channel=${encodeURIComponent(state.selectedTextChannel)}&type=text&limit=80`); state.text = result.data || []; renderText(); }
function renderText() { el.textList.replaceChildren(); el.reviewList.replaceChildren(); if (!state.selectedTextChannel) { appendEmpty(el.textList, 'Select channel to view text captures'); appendEmpty(el.reviewList, 'No warned or flagged messages'); return; } if (state.text.length === 0) { appendEmpty(el.textList, 'No text captures yet'); appendEmpty(el.reviewList, 'No warned or flagged messages'); return; } const reviewMessages = []; for (const msg of state.text) { const metadata = parseMetadata(msg.metadata); const card = document.createElement('article'); card.className = 'event-card'; const head = document.createElement('div'); head.className = 'event-head'; const author = document.createElement('div'); author.className = 'author'; const avatar = document.createElement('div'); avatar.className = 'avatar'; if (msg.avatar_url) { const img = document.createElement('img'); img.src = msg.avatar_url; img.alt = ''; avatar.appendChild(img); } const name = document.createElement('div'); name.className = 'name'; name.textContent = msg.username || msg.user_id; author.append(avatar, name); const time = document.createElement('div'); time.className = 'time'; time.textContent = new Date(msg.created_at).toLocaleString(); head.append(author, time); const text = document.createElement('div'); text.className = 'message-text'; text.textContent = msg.edited_content || msg.content || '(empty message)'; card.append(head, text); appendAIAnalysis(card, msg); appendMedia(card, metadata); const badges = document.createElement('div'); badges.className = 'badges'; if (metadata.reference?.messageId) appendBadge(badges, 'reply', ''); if (msg.thread_id) appendBadge(badges, metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : 'thread', ''); if (msg.edited_at) appendBadge(badges, 'edited', 'edit'); if (msg.deleted_at) appendBadge(badges, 'deleted', 'delete'); card.appendChild(badges); el.textList.appendChild(card); if (msg.ai_status === 'warn' || msg.ai_status === 'flagged') reviewMessages.push(card.cloneNode(true)); } if (reviewMessages.length === 0) { appendEmpty(el.reviewList, 'No warned or flagged messages'); } else { reviewMessages.forEach((card) => el.reviewList.appendChild(card)); } }
function appendAIAnalysis(card, msg) { const status = msg.ai_status || 'pending'; const wrap = document.createElement('div'); wrap.className = 'badges'; const badge = document.createElement('span'); badge.className = `badge ${status === 'flagged' ? 'delete' : status === 'clean' ? 'edit' : ''}`; badge.textContent = `AI: ${status}`; wrap.appendChild(badge); if (msg.ai_moderation_flags) { const flags = document.createElement('span'); flags.className = 'badge delete'; try { flags.textContent = JSON.parse(msg.ai_moderation_flags).join(', '); } catch { flags.textContent = msg.ai_moderation_flags; } wrap.appendChild(flags); } card.appendChild(wrap); if (msg.ai_analysis) { const analysis = document.createElement('div'); analysis.className = 'embed-description'; analysis.textContent = msg.ai_analysis; card.appendChild(analysis); } if (msg.ai_error) { const error = document.createElement('div'); error.className = 'embed-description'; error.textContent = `AI error: ${msg.ai_error}`; card.appendChild(error); } }
function appendMedia(card, metadata) { const stickers = document.createElement('div'); stickers.className = 'sticker-strip'; for (const sticker of metadata.stickers || []) { const img = document.createElement('img'); img.className = 'sticker-img'; img.src = sticker.url; img.alt = sticker.name; stickers.appendChild(img); } if (stickers.childElementCount) card.appendChild(stickers); const embeds = document.createElement('div'); embeds.className = 'feed'; for (const embed of metadata.embeds || []) { const item = document.createElement('div'); item.className = 'embed-card'; if (embed.title) { const title = document.createElement(embed.url ? 'a' : 'div'); title.className = 'embed-title'; title.textContent = embed.title; if (embed.url) { title.href = embed.url; title.target = '_blank'; title.rel = 'noreferrer'; } item.appendChild(title); } if (embed.description) { const desc = document.createElement('div'); desc.className = 'embed-description'; desc.textContent = embed.description; item.appendChild(desc); } if (embed.image || embed.thumbnail) { const img = document.createElement('img'); img.className = 'embed-image'; img.src = embed.image || embed.thumbnail; img.alt = embed.title || 'embed image'; item.appendChild(img); } embeds.appendChild(item); } if (embeds.childElementCount) card.appendChild(embeds); const attachments = document.createElement('div'); attachments.className = 'attachment-strip'; for (const attachment of metadata.attachments || []) { const link = document.createElement('a'); link.className = 'attachment-chip'; link.href = attachment.url; link.target = '_blank'; link.rel = 'noreferrer'; link.textContent = `${attachment.name} (${(attachment.size / 1024).toFixed(1)}KB)`; attachments.appendChild(link); } if (attachments.childElementCount) card.appendChild(attachments); }
function handleIncomingPCM(data) { if (!state.localListening || !state.audioContextListen) return; const headerView = new DataView(data, 0, 4); const userIdHash = headerView.getInt32(0, true); const audioData = data.slice(4); const int16Array = new Int16Array(audioData); const float32Array = new Float32Array(int16Array.length); for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768; const audioBuffer = state.audioContextListen.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE); const nowBuffering = audioBuffer.getChannelData(0); for (let i = 0; i < audioBuffer.length; i++) nowBuffering[i] = float32Array[i]; const source = state.audioContextListen.createBufferSource(); source.buffer = audioBuffer; source.connect(state.audioContextListen.destination); const currentTime = state.audioContextListen.currentTime; let userNextStartTime = state.userTimelines.get(userIdHash) || 0; if (userNextStartTime < currentTime) userNextStartTime = currentTime + 0.05; source.start(userNextStartTime); userNextStartTime += audioBuffer.duration; state.userTimelines.set(userIdHash, userNextStartTime); }
async function startStreamingLocal() { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); state.localStreaming = true; el.toggleBtn.textContent = 'Stop Transmitting'; state.audioContextTransmit = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE }); const source = state.audioContextTransmit.createMediaStreamSource(stream); const analyser = state.audioContextTransmit.createAnalyser(); analyser.fftSize = 64; source.connect(analyser); const dataArray = new Uint8Array(analyser.frequencyBinCount); state.processor = state.audioContextTransmit.createScriptProcessor(4096, 1, 1); source.connect(state.processor); state.processor.connect(state.audioContextTransmit.destination); state.processor.onaudioprocess = (event) => { if (!state.localStreaming || state.socket.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; state.socket.send(pcmData.buffer); analyser.getByteFrequencyData(dataArray); bars.forEach((bar, index) => { const percent = (dataArray[index] / 255) * 100; bar.style.height = `${Math.max(2, percent)}%`; }); }; }
function stopStreamingLocal() { state.localStreaming = false; if (state.processor) state.processor.disconnect(); if (state.audioContextTransmit) state.audioContextTransmit.close(); state.processor = null; state.audioContextTransmit = null; el.toggleBtn.textContent = 'Start Transmitting'; bars.forEach((bar) => { bar.style.height = '2px'; }); }
async function startListeningLocal(auto = false) { if (!state.audioContextListen) state.audioContextListen = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE }); await state.audioContextListen.resume(); state.localListening = true; state.mediaAutoListening = auto; renderListenStatus(); }
function stopListeningLocal() { state.audioContextListen?.suspend(); state.userTimelines.clear(); state.localListening = false; state.mediaAutoListening = false; renderListenStatus(); }
function renderListenStatus() { el.listenBtn.textContent = state.isListening ? 'Stop Listening' : 'Join Listen Channel'; el.listenStatus.textContent = state.localListening ? (state.media.current && state.mediaAutoListening ? 'Media Monitor On' : 'Listening Live...') : 'Speaker Off'; }
function updateVisualizer(level) { bars.forEach((bar, index) => { const wave = Math.sin(index * 0.55 + Date.now() / 140) * 0.35 + 0.65; bar.style.height = `${Math.max(3, level * 190 * wave)}px`; }); }
document.querySelectorAll('.tab-btn').forEach((button) => { button.addEventListener('click', () => postUIState({ activeTab: button.dataset.tab }).catch((error) => showError(error.message))); });
el.voiceGuildSelect.addEventListener('change', () => postUIState({ selectedVoiceGuild: el.voiceGuildSelect.value, selectedVoiceChannel: '' }).catch((error) => showError(error.message)));
el.textGuildSelect.addEventListener('change', () => postUIState({ selectedTextGuild: el.textGuildSelect.value, selectedTextChannel: '' }).catch((error) => showError(error.message)));
el.channelSelect.addEventListener('change', () => postUIState({ selectedVoiceChannel: el.channelSelect.value }).catch((error) => showError(error.message)));
el.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message)));
el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message)));
el.toggleBtn.addEventListener('click', () => postUIState({ isStreaming: !state.isStreaming }).catch((error) => showError(error.message)));
el.listenBtn.addEventListener('click', () => postUIState({ isListening: !state.isListening }).catch((error) => showError(error.message)));
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.textGuildSelect.value) url.searchParams.set('guild', el.textGuildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
async function fetchMediaStatus() { state.media = await apiRequest('/api/media/status'); renderMedia(); }
async function queueMedia() { const source = el.mediaSourceInput.value.trim(); if (!source) return showError('Enter a music URL or local file path'); if (state.isStreaming || state.localStreaming) await postUIState({ isStreaming: false }); state.media = await apiRequest('/api/media/queue', { method: 'POST', body: JSON.stringify({ source }) }); el.mediaSourceInput.value = ''; await reconcileDynamicAudio(); renderMedia(); }
async function skipMedia() { state.media = await apiRequest('/api/media/skip', { method: 'POST' }); await reconcileDynamicAudio(); renderMedia(); }
async function stopMedia() { state.media = await apiRequest('/api/media/stop', { method: 'POST' }); await reconcileDynamicAudio(); renderMedia(); }
function renderMedia() { el.mediaQueueList.replaceChildren(); const current = state.media.current; el.mediaStatus.textContent = current ? `Playing ${current.title}` : 'Idle'; if (current) { const item = document.createElement('div'); item.className = 'event-card'; item.textContent = `Now: ${current.title}`; el.mediaQueueList.appendChild(item); } for (const queued of state.media.queue || []) { const item = document.createElement('div'); item.className = 'event-card'; item.textContent = queued.title; el.mediaQueueList.appendChild(item); } if (!current && (!state.media.queue || state.media.queue.length === 0)) appendEmpty(el.mediaQueueList, 'No media queued'); }
el.queueMediaBtn.addEventListener('click', () => queueMedia().catch((error) => showError(error.message)));
el.skipMediaBtn.addEventListener('click', () => skipMedia().catch((error) => showError(error.message)));
el.stopMediaBtn.addEventListener('click', () => stopMedia().catch((error) => showError(error.message)));
connectWebSocket();
apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).then(fetchMediaStatus).catch((error) => showError(error.message));
setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000);
</script>
</body>
</html>

View File

@@ -1,4 +1,3 @@
import "./mock-crc";
import "libsodium-wrappers"; import "libsodium-wrappers";
import "@snazzah/davey"; import "@snazzah/davey";
import "dotenv/config"; import "dotenv/config";

View File

@@ -1,10 +1,15 @@
import fs from "node:fs"; import fs from "node:fs";
import http from "node:http"; import http from "node:http";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import { Streamer } from "@dank074/discord-video-stream"; import { Streamer } from "@dank074/discord-video-stream";
import { AudioPlayerStatus } from "@discordjs/voice"; import { AudioPlayerStatus } from "@discordjs/voice";
import type { Client } from "discord.js-selfbot-v13"; import type { Client } from "discord.js-selfbot-v13";
import express from "express"; import express, {
type NextFunction,
type Request,
type Response,
} from "express";
import helmet from "helmet"; import helmet from "helmet";
import * as prism from "prism-media"; import * as prism from "prism-media";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
@@ -25,6 +30,9 @@ import { createUIStateRoutes } from "./routes/uiStateRoutes";
import { createVoiceRoutes } from "./routes/voiceRoutes"; import { createVoiceRoutes } from "./routes/voiceRoutes";
import type { VoiceController } from "./voiceController"; import type { VoiceController } from "./voiceController";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const wsLogger = createChildLogger("webserver"); const wsLogger = createChildLogger("webserver");
const activeUsers = new Map< const activeUsers = new Map<
@@ -70,11 +78,11 @@ let sharedUIState: SharedUIState = { ...defaultSharedUIState };
export function normalizeSharedUIState( export function normalizeSharedUIState(
value: SharedUIStatePatch, value: SharedUIStatePatch,
): SharedUIState { ): SharedUIState {
const legacyGuild = value.selectedGuild ?? ""; const guild = value.selectedGuild ?? "";
return { return {
selectedVoiceGuild: value.selectedVoiceGuild ?? legacyGuild, selectedVoiceGuild: value.selectedVoiceGuild ?? guild,
selectedVoiceChannel: value.selectedVoiceChannel ?? "", selectedVoiceChannel: value.selectedVoiceChannel ?? "",
selectedTextGuild: value.selectedTextGuild ?? legacyGuild, selectedTextGuild: value.selectedTextGuild ?? guild,
selectedTextChannel: value.selectedTextChannel ?? "", selectedTextChannel: value.selectedTextChannel ?? "",
activeTab: value.activeTab === "text" ? "text" : "voice", activeTab: value.activeTab === "text" ? "text" : "voice",
isListening: value.isListening ?? false, isListening: value.isListening ?? false,
@@ -124,13 +132,15 @@ function patchSharedUIState(patch: SharedUIStatePatch) {
// Upsample 24kHz mono s16le → 48kHz stereo s16le (pure JS) // Upsample 24kHz mono s16le → 48kHz stereo s16le (pure JS)
function upsample(mono24k: Buffer): Buffer { function upsample(mono24k: Buffer): Buffer {
const out = Buffer.alloc(mono24k.length * 4); const numSamples = mono24k.length / 2;
for (let i = 0; i < mono24k.length / 2; i++) { const out = Buffer.alloc(numSamples * 8);
for (let i = 0; i < numSamples; i++) {
const s = mono24k.readInt16LE(i * 2); const s = mono24k.readInt16LE(i * 2);
out.writeInt16LE(s, i * 8); const base = i * 8;
out.writeInt16LE(s, i * 8 + 2); out.writeInt16LE(s, base);
out.writeInt16LE(s, i * 8 + 4); out.writeInt16LE(s, base + 2);
out.writeInt16LE(s, i * 8 + 6); out.writeInt16LE(s, base + 4);
out.writeInt16LE(s, base + 6);
} }
return out; return out;
} }
@@ -144,7 +154,7 @@ function rmsDb(pcm: Buffer): number {
sum += s * s; sum += s * s;
} }
const rms = Math.sqrt(sum / samples); const rms = Math.sqrt(sum / samples);
return 20 * Math.log10(Math.max(rms, 1e-10)); return 20 * Math.log10(rms);
} }
export async function startWebserver( export async function startWebserver(
@@ -185,7 +195,7 @@ export async function startWebserver(
}), }),
); );
app.use((req, res, next) => { app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith("/api/")) { if (req.path.startsWith("/api/")) {
res.set("Cache-Control", "no-store"); res.set("Cache-Control", "no-store");
} }
@@ -209,17 +219,19 @@ export async function startWebserver(
app.use(express.static(path.join(__dirname, "../public"))); app.use(express.static(path.join(__dirname, "../public")));
app.get("/", (_req, res) => { app.get("/", (_req: Request, res: Response) => {
const reactIndex = path.join(__dirname, "../public/app/index.html"); const reactIndex = path.join(__dirname, "../public/app/index.html");
if (fs.existsSync(reactIndex)) { if (fs.existsSync(reactIndex)) {
res.sendFile(reactIndex); res.sendFile(reactIndex);
} else { return;
res.sendFile(path.join(__dirname, "../public/index.html"));
} }
res
.status(503)
.send("React dashboard is not built. Run pnpm run build:web.");
}); });
// Health check endpoint // Health check endpoint
app.get("/health", (_req, res) => { app.get("/health", (_req: Request, res: Response) => {
res.json({ res.json({
status: "ok", status: "ok",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -230,7 +242,7 @@ export async function startWebserver(
}); });
// Metrics endpoint // Metrics endpoint
app.get("/metrics", async (_req, res) => { app.get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", "text/plain"); res.set("Content-Type", "text/plain");
uptimeGauge.set(process.uptime()); uptimeGauge.set(process.uptime());
res.send(await getMetrics()); res.send(await getMetrics());

27
tailwind.config.js Normal file
View File

@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./frontend/index.html", "./frontend/src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};

View File

@@ -11,7 +11,8 @@
"rootDir": "src", "rootDir": "src",
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"jsx": "react-jsx" "jsx": "react-jsx",
"resolveJsonModule": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]