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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3c7d722973
commit
82025a19b2
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
+154
-109
@@ -1,124 +1,169 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { listMessages, reanalyzeMessage } from "./api/client";
|
||||
import { connectDashboardSocket } from "./ws/client";
|
||||
import type { MessageRecord } from "./api/client";
|
||||
import type { DashboardEvent } from "./ws/client";
|
||||
import { MessageFeed } from "./components/messages/MessageFeed";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { DashboardLayout } from "./components/layout/DashboardLayout";
|
||||
import { MediaPanel } from "./components/media/MediaPanel";
|
||||
import { MessagesPanel } from "./components/messages/MessagesPanel";
|
||||
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(
|
||||
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);
|
||||
}
|
||||
const SAMPLE_RATE = 24000;
|
||||
const CHANNELS = 1;
|
||||
|
||||
export default function App() {
|
||||
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
||||
const [wsStatus, setWsStatus] = useState<string>("connecting");
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const { uiState, setUIState, patchUIState } = useUIState();
|
||||
const voice = useVoiceControl();
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
||||
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
|
||||
|
||||
listMessages(new URLSearchParams({ limit: "30" }))
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setMessages(mergeMessages([], result.data));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to load messages:", err);
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
if (selectedTextGuild) voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
|
||||
}, [selectedTextGuild, voice.loadTextTargets]);
|
||||
|
||||
const ws = connectDashboardSocket((event: DashboardEvent) => {
|
||||
switch (event.type) {
|
||||
case "message_created":
|
||||
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;
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
|
||||
}, [selectedTextChannel, messages.fetchMessages]);
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.addEventListener("open", () => setWsStatus("connected"));
|
||||
ws.addEventListener("close", () => setWsStatus("disconnected"));
|
||||
ws.addEventListener("error", () => setWsStatus("error"));
|
||||
|
||||
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 toggleListening = useCallback(async () => {
|
||||
if (isListening) {
|
||||
await audioContextRef.current?.suspend();
|
||||
userTimelinesRef.current.clear();
|
||||
setIsListening(false);
|
||||
await patchUIState({ isListening: false });
|
||||
return;
|
||||
}
|
||||
};
|
||||
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 (
|
||||
<div className="app">
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">Moderation</div>
|
||||
<div className="sidebar-placeholder">Channels placeholder</div>
|
||||
<DashboardLayout
|
||||
activeTab={activeTab}
|
||||
wsStatus={socket.status}
|
||||
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 className="main">
|
||||
<div className="header">
|
||||
<h1>Discord Moderation Dashboard</h1>
|
||||
<span className="ws-status" data-status={wsStatus}>
|
||||
{wsStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<MessageFeed messages={messages} onReanalyze={handleReanalyze} />
|
||||
</div>
|
||||
|
||||
<ReviewPanel messages={messages} onReanalyze={handleReanalyze} />
|
||||
</div>
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||
<TabsContent value="voice">
|
||||
<VoicePanel
|
||||
guilds={voice.guilds}
|
||||
channels={voice.voiceChannels}
|
||||
selectedGuild={selectedVoiceGuild}
|
||||
selectedChannel={selectedVoiceChannel}
|
||||
status={voice.voiceStatus}
|
||||
loading={voice.loading}
|
||||
activeSpeakers={activeSpeakers}
|
||||
levels={levels}
|
||||
isListening={isListening}
|
||||
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
|
||||
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
|
||||
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { listMessages, listReview, reanalyzeMessage } from "./client";
|
||||
|
||||
export { listMessages, listReview, reanalyzeMessage };
|
||||
@@ -0,0 +1,13 @@
|
||||
import { request } from "./client";
|
||||
import type { UIState } from "../types/ui";
|
||||
|
||||
export function getUIState(): Promise<UIState> {
|
||||
return request<UIState>('/api/ui-state');
|
||||
}
|
||||
|
||||
export function updateUIState(patch: Partial<UIState>): Promise<UIState> {
|
||||
return request<UIState>('/api/ui-state', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,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' });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { DashboardTab } from "../../types/ui";
|
||||
import type { VoiceStatus } from "../../types/voice";
|
||||
import type { WebSocketStatus } from "../../hooks/useDashboardSocket";
|
||||
import { Header } from "./Header";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
activeTab: DashboardTab;
|
||||
wsStatus: WebSocketStatus;
|
||||
voiceStatus: VoiceStatus;
|
||||
onTabChange: (tab: DashboardTab) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardLayout({ activeTab, wsStatus, voiceStatus, onTabChange, children }: DashboardLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar activeTab={activeTab} onTabChange={onTabChange} />
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<Header activeTab={activeTab} wsStatus={wsStatus} voiceStatus={voiceStatus} />
|
||||
<div className="flex-1 overflow-auto p-4 md:p-6 lg:p-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import type { WebSocketStatus } from "../../hooks/useDashboardSocket";
|
||||
import type { DashboardTab } from "../../types/ui";
|
||||
import type { VoiceStatus } from "../../types/voice";
|
||||
import { Badge } from "../ui/badge";
|
||||
|
||||
const titles: Record<DashboardTab, string> = {
|
||||
voice: "Voice Control",
|
||||
media: "Media Player",
|
||||
messages: "Messages",
|
||||
review: "Moderation Review",
|
||||
};
|
||||
|
||||
interface HeaderProps {
|
||||
activeTab: DashboardTab;
|
||||
wsStatus: WebSocketStatus;
|
||||
voiceStatus: VoiceStatus;
|
||||
}
|
||||
|
||||
export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 px-4 py-4 backdrop-blur md:px-8">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{titles[activeTab]}</h1>
|
||||
<p className="text-sm text-muted-foreground">Voice, media, and moderation in one dashboard.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={wsStatus === "connected" ? "success" : wsStatus === "error" ? "destructive" : "warning"}>
|
||||
{wsStatus === "connected" ? <Wifi className="mr-1 h-3 w-3" /> : <WifiOff className="mr-1 h-3 w-3" />}
|
||||
WebSocket {wsStatus}
|
||||
</Badge>
|
||||
<Badge variant={voiceStatus.connected ? "success" : "secondary"}>
|
||||
Voice {voiceStatus.connected ? voiceStatus.activeChannelName || "connected" : "idle"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Bot, MessageSquare, Music2, ShieldAlert, Volume2 } from "lucide-react";
|
||||
import type { DashboardTab } from "../../types/ui";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Volume2 }> = [
|
||||
{ id: "voice", label: "Voice", icon: Volume2 },
|
||||
{ id: "media", label: "Media", icon: Music2 },
|
||||
{ id: "messages", label: "Messages", icon: MessageSquare },
|
||||
{ id: "review", label: "Review", icon: ShieldAlert },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: DashboardTab;
|
||||
onTabChange: (tab: DashboardTab) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
|
||||
return (
|
||||
<aside className="hidden w-72 shrink-0 border-r border-border bg-card/60 p-5 backdrop-blur md:block">
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/15 text-primary">
|
||||
<Bot className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold tracking-tight">Bete Watcher</div>
|
||||
<div className="text-xs text-muted-foreground">Discord control center</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant={activeTab === item.id ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start", activeTab === item.id && "bg-primary/15 text-primary")}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { MediaState } from "../../types/media";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
|
||||
interface MediaQueueProps {
|
||||
state: MediaState;
|
||||
}
|
||||
|
||||
export function MediaQueue({ state }: MediaQueueProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Now Playing</CardTitle>
|
||||
<CardDescription>Current item and queue state.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{state.current ? (
|
||||
<div className="rounded-xl border border-primary/30 bg-primary/10 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{state.current.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{state.current.source}</div>
|
||||
</div>
|
||||
<Badge variant={state.current.mode === "screen" ? "warning" : "success"}>{state.current.mode || "music"}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">No media playing.</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Queue</div>
|
||||
{state.queue.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Queue is empty.</div>
|
||||
) : (
|
||||
state.queue.map((item, index) => (
|
||||
<div key={`${item.source}-${index}`} className="rounded-lg border border-border bg-background/60 p-3 text-sm">
|
||||
<div className="font-medium">{item.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{item.source}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MonitorUp } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
interface ScreenShareProps {
|
||||
loading: boolean;
|
||||
onStart: (source: string) => void;
|
||||
onSkip: () => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function ScreenShare({ loading, onStart, onSkip, onStop }: ScreenShareProps) {
|
||||
const [source, setSource] = useState("");
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) return;
|
||||
onStart(trimmed);
|
||||
setSource("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><MonitorUp className="h-5 w-5" /> Screen Share</CardTitle>
|
||||
<CardDescription>Start screen-share playback from a URL or local file path.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(event) => setSource(event.target.value)}
|
||||
onKeyDown={(event) => event.key === "Enter" && submit()}
|
||||
placeholder="Screen share URL or local file path"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}>Start Screen Share</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</Button>
|
||||
<Button variant="destructive" disabled={loading} onClick={onStop}>Stop</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { MessageMetadata, MessageRecord } from "../../types/messages";
|
||||
|
||||
function parseMetadata(value: string | null): MessageMetadata {
|
||||
if (!value) return {};
|
||||
try {
|
||||
return JSON.parse(value) as MessageMetadata;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
||||
const images = messages.flatMap((message) => {
|
||||
const metadata = parseMetadata(message.metadata);
|
||||
const attachments = metadata.attachments ?? [];
|
||||
const embeds = metadata.embeds ?? [];
|
||||
return [
|
||||
...attachments
|
||||
.filter((attachment) => attachment.url && (attachment.contentType?.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(attachment.name)))
|
||||
.map((attachment) => ({ url: attachment.url, title: attachment.name, message })),
|
||||
...embeds
|
||||
.flatMap((embed) => [embed.image, embed.thumbnail].filter(Boolean).map((url) => ({ url: url as string, title: embed.title || "embed image", message }))),
|
||||
];
|
||||
});
|
||||
|
||||
if (images.length === 0) {
|
||||
return <div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">No images found.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{images.map((image, index) => (
|
||||
<a key={`${image.url}-${index}`} href={image.url} target="_blank" rel="noreferrer" className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
|
||||
<img src={image.url} alt={image.title} className="aspect-video w-full object-cover transition-transform group-hover:scale-105" />
|
||||
<div className="p-3">
|
||||
<div className="truncate text-sm font-medium">{image.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{image.message.username}</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +1,51 @@
|
||||
import type { MessageRecord } from "../../api/client";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import type { MessageRecord } from "../../types/messages";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
export interface MessageCardProps {
|
||||
message: MessageRecord;
|
||||
onReanalyze: (id: string) => void;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: "#f9e2af",
|
||||
clean: "#a6e3a1",
|
||||
warn: "#fab387",
|
||||
flagged: "#f38ba8",
|
||||
error: "#f38ba8",
|
||||
};
|
||||
function aiVariant(status: string) {
|
||||
if (status === "clean") return "success";
|
||||
if (status === "warn") return "warning";
|
||||
if (status === "flagged" || status === "error") return "destructive";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
||||
const displayContent = message.edited_content ?? message.content;
|
||||
const aiStatus = message.ai_status ?? "pending";
|
||||
const statusColor = STATUS_COLORS[aiStatus] ?? "#6c7086";
|
||||
|
||||
return (
|
||||
<div className={`message-card type-${message.type}`}>
|
||||
<img
|
||||
src={message.avatar_url ?? "/default-avatar.png"}
|
||||
alt={message.username}
|
||||
className="message-card-avatar"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<div className="message-card-body">
|
||||
<div className="message-card-meta">
|
||||
<span className="message-card-username">{message.username}</span>
|
||||
<span className="message-card-time">
|
||||
{new Date(message.created_at).toLocaleString()}
|
||||
</span>
|
||||
{message.type === "edited" && (
|
||||
<span className="badge badge-edited">edited</span>
|
||||
)}
|
||||
{message.type === "deleted" && (
|
||||
<span className="badge badge-deleted">deleted</span>
|
||||
)}
|
||||
<span
|
||||
className="badge badge-ai"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
title={`AI: ${aiStatus}`}
|
||||
>
|
||||
{aiStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="message-card-content">{displayContent}</p>
|
||||
|
||||
{message.ai_analysis && (
|
||||
<div className="message-card-analysis">{message.ai_analysis}</div>
|
||||
)}
|
||||
|
||||
{message.ai_error && (
|
||||
<div className="message-card-error">{message.ai_error}</div>
|
||||
)}
|
||||
|
||||
<div className="message-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-reanalyze"
|
||||
onClick={() => onReanalyze(message.id)}
|
||||
disabled={aiStatus === "pending"}
|
||||
>
|
||||
Reanalyze
|
||||
</button>
|
||||
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
|
||||
<div className="flex gap-3">
|
||||
<img
|
||||
src={message.avatar_url ?? "/default-avatar.png"}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{message.username || message.user_id}</span>
|
||||
<span className="text-xs text-muted-foreground">{new Date(message.created_at).toLocaleString()}</span>
|
||||
{message.edited_at ? <Badge variant="outline">edited</Badge> : null}
|
||||
{message.deleted_at ? <Badge variant="destructive">deleted</Badge> : null}
|
||||
<Badge variant={aiVariant(aiStatus)}>{aiStatus}</Badge>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
|
||||
{displayContent || "(empty message)"}
|
||||
</p>
|
||||
{message.ai_analysis ? <div className="rounded-xl bg-muted p-3 text-sm text-muted-foreground">{message.ai_analysis}</div> : null}
|
||||
{message.ai_error ? <div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">AI error: {message.ai_error}</div> : null}
|
||||
<Button size="sm" variant="outline" onClick={() => onReanalyze(message.id)} disabled={aiStatus === "pending"}>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
Re-analyze
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import type { MessageRecord } from "../../api/client";
|
||||
import type { MessageRecord } from "../../types/messages";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import { MessageCard } from "./MessageCard";
|
||||
|
||||
export interface MessageFeedProps {
|
||||
messages: MessageRecord[];
|
||||
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) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<p>No messages yet</p>
|
||||
</div>
|
||||
);
|
||||
return <div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">{emptyText}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message-feed">
|
||||
{messages.map((msg) => (
|
||||
<MessageCard key={msg.id} message={msg} onReanalyze={onReanalyze} />
|
||||
))}
|
||||
</div>
|
||||
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => (
|
||||
<MessageCard key={message.id} message={message} onReanalyze={onReanalyze} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Channel, Guild } from "../../types/voice";
|
||||
import type { MessageRecord } from "../../types/messages";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Select } from "../ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import { ImageGrid } from "./ImageGrid";
|
||||
import { MessageFeed } from "./MessageFeed";
|
||||
|
||||
interface MessagesPanelProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
messages: MessageRecord[];
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
onReanalyze: (id: string) => void;
|
||||
}
|
||||
|
||||
export function MessagesPanel({
|
||||
guilds,
|
||||
channels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
messages,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
onReanalyze,
|
||||
}: MessagesPanelProps) {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Message Source</CardTitle>
|
||||
<CardDescription>Pick a guild and channel/thread to inspect captures.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<Select
|
||||
value={selectedGuild}
|
||||
onChange={(event) => onGuildChange(event.target.value)}
|
||||
placeholder="Select text guild"
|
||||
options={guilds.map((guild) => ({ value: guild.id, label: guild.name }))}
|
||||
/>
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onChange={(event) => onChannelChange(event.target.value)}
|
||||
placeholder="Select channel or thread"
|
||||
options={channels.map((channel) => ({ value: channel.id, label: channel.name }))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Tabs defaultValue="all">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All Messages</TabsTrigger>
|
||||
<TabsTrigger value="images">Images</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="all">
|
||||
<MessageFeed messages={messages} onReanalyze={onReanalyze} emptyText={selectedChannel ? "No captures yet." : "Select a channel to view captures."} />
|
||||
</TabsContent>
|
||||
<TabsContent value="images">
|
||||
<ImageGrid messages={messages} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MessageRecord } from "../../api/client";
|
||||
import { MessageCard } from "../messages/MessageCard";
|
||||
import type { MessageRecord } from "../../types/messages";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { MessageFeed } from "../messages/MessageFeed";
|
||||
|
||||
export interface ReviewPanelProps {
|
||||
messages: MessageRecord[];
|
||||
@@ -8,30 +9,21 @@ export interface ReviewPanelProps {
|
||||
|
||||
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
|
||||
const reviewItems = messages.filter(
|
||||
(m) =>
|
||||
m.ai_status === "warn" ||
|
||||
m.ai_status === "flagged" ||
|
||||
m.ai_status === "error",
|
||||
(message) =>
|
||||
message.ai_status === "warn" ||
|
||||
message.ai_status === "flagged" ||
|
||||
message.ai_status === "error",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="review-panel">
|
||||
<div className="review-header">
|
||||
<h2>Needs Review</h2>
|
||||
<span className="review-count">{reviewItems.length}</span>
|
||||
</div>
|
||||
|
||||
{reviewItems.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No items to review</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="review-list">
|
||||
{reviewItems.map((msg) => (
|
||||
<MessageCard key={msg.id} message={msg} onReanalyze={onReanalyze} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Needs Review</CardTitle>
|
||||
<CardDescription>{reviewItems.length} captured messages require attention.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MessageFeed messages={reviewItems} onReanalyze={onReanalyze} emptyText="No warned, flagged, or errored messages." />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type BadgeVariant = "default" | "secondary" | "destructive" | "outline" | "success" | "warning";
|
||||
|
||||
const variants: Record<BadgeVariant, string> = {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-emerald-500/15 text-emerald-300",
|
||||
warning: "border-transparent bg-amber-500/15 text-amber-300",
|
||||
};
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: BadgeVariant;
|
||||
}
|
||||
|
||||
export function Badge({ className, variant = "default", ...props }: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
variants[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import type * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type ButtonVariant = "default" | "secondary" | "destructive" | "outline" | "ghost";
|
||||
type ButtonSize = "default" | "sm" | "lg" | "icon";
|
||||
|
||||
const variants: Record<ButtonVariant, string> = {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
};
|
||||
|
||||
const sizes: Record<ButtonSize, string> = {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
};
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
asChild?: boolean;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("rounded-2xl border border-border bg-card text-card-foreground shadow-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex items-center p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function Input({ className, type, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import type * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function ScrollArea({ className, children, ...props }: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root className={cn("relative overflow-hidden", className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({ className, orientation = "vertical", ...props }: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function Select({ className, options, placeholder, ...props }: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{placeholder ? <option value="">{placeholder}</option> : null}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import type * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export const Tabs = TabsPrimitive.Root;
|
||||
|
||||
export function TabsList({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn("inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsTrigger({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsContent({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
className={cn("mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ActiveSpeaker } from "../../types/voice";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
|
||||
interface ActiveSpeakersProps {
|
||||
speakers: ActiveSpeaker[];
|
||||
}
|
||||
|
||||
export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Speakers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{speakers.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
No active speakers.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{speakers.map((speaker, index) => (
|
||||
<div key={speaker.userId || speaker.id || index} className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3">
|
||||
<img src={speaker.avatar} alt="" className="h-10 w-10 rounded-full object-cover" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{speaker.username}</div>
|
||||
<div className="text-xs text-emerald-300">Speaking</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
interface AudioVisualizerProps {
|
||||
levels: number[];
|
||||
}
|
||||
|
||||
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
const bars = levels.length ? levels : Array.from({ length: 32 }, () => 0.04);
|
||||
return (
|
||||
<div className="flex h-40 items-end gap-1 rounded-2xl border border-border bg-background/60 p-4">
|
||||
{bars.map((level, index) => (
|
||||
<div
|
||||
key={`${index}-${level}`}
|
||||
className="flex-1 rounded-full bg-gradient-to-t from-primary/50 to-cyan-300 transition-all duration-150"
|
||||
style={{ height: `${Math.max(6, Math.min(100, level * 100))}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { MessageRecord } from "../types/messages";
|
||||
import type { MediaState } from "../types/media";
|
||||
import type { UIState } from "../types/ui";
|
||||
import type { ActiveSpeaker } from "../types/voice";
|
||||
|
||||
export type WebSocketStatus = "connecting" | "connected" | "disconnected" | "error";
|
||||
|
||||
export interface DashboardSocketHandlers {
|
||||
onUIState?: (state: UIState) => void;
|
||||
onUserState?: (users: ActiveSpeaker[]) => void;
|
||||
onMessageCreated?: (message: MessageRecord) => void;
|
||||
onMessageUpdated?: (message: Partial<MessageRecord> & { id: string }) => void;
|
||||
onMessageDeleted?: (message: { id: string }) => void;
|
||||
onMessageAnalyzed?: (message: MessageRecord) => void;
|
||||
onAttachmentUploaded?: () => void;
|
||||
onMediaState?: (state: MediaState) => void;
|
||||
onPcm?: (data: ArrayBuffer) => void;
|
||||
}
|
||||
|
||||
export function useDashboardSocket(handlers: DashboardSocketHandlers) {
|
||||
const [status, setStatus] = useState<WebSocketStatus>("connecting");
|
||||
const handlersRef = useRef(handlers);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
|
||||
handlersRef.current = handlers;
|
||||
|
||||
useEffect(() => {
|
||||
let closed = false;
|
||||
let reconnectTimer: number | null = null;
|
||||
|
||||
const connect = () => {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(`${protocol}//${location.host}/ws`);
|
||||
socket.binaryType = "arraybuffer";
|
||||
socketRef.current = socket;
|
||||
setStatus("connecting");
|
||||
|
||||
socket.addEventListener("open", () => setStatus("connected"));
|
||||
socket.addEventListener("error", () => setStatus("error"));
|
||||
socket.addEventListener("close", () => {
|
||||
setStatus("disconnected");
|
||||
if (!closed) reconnectTimer = window.setTimeout(connect, 2500);
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
handlersRef.current.onPcm?.(event.data);
|
||||
return;
|
||||
}
|
||||
if (typeof event.data !== "string") return;
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
switch (message.type) {
|
||||
case "ui_state":
|
||||
handlersRef.current.onUIState?.(message.state);
|
||||
break;
|
||||
case "user_state":
|
||||
handlersRef.current.onUserState?.(message.users || []);
|
||||
break;
|
||||
case "message_created":
|
||||
handlersRef.current.onMessageCreated?.(message.data);
|
||||
break;
|
||||
case "message_updated":
|
||||
handlersRef.current.onMessageUpdated?.(message.data);
|
||||
break;
|
||||
case "message_deleted":
|
||||
handlersRef.current.onMessageDeleted?.(message.data);
|
||||
break;
|
||||
case "message_analyzed":
|
||||
handlersRef.current.onMessageAnalyzed?.(message.data);
|
||||
break;
|
||||
case "attachment_uploaded":
|
||||
handlersRef.current.onAttachmentUploaded?.();
|
||||
break;
|
||||
case "media_state":
|
||||
handlersRef.current.onMediaState?.(message.state);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed socket messages
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
if (reconnectTimer) window.clearTimeout(reconnectTimer);
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { status, socketRef };
|
||||
}
|
||||
@@ -0,0 +1,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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getUIState, updateUIState } from "../api/uiState";
|
||||
import type { UIState } from "../types/ui";
|
||||
|
||||
export function useUIState() {
|
||||
const [uiState, setUIState] = useState<UIState>({ activeTab: "voice" });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getUIState()
|
||||
.then((state) => {
|
||||
if (!cancelled) setUIState({ activeTab: "voice", ...state });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const patchUIState = useCallback(async (patch: Partial<UIState>) => {
|
||||
setUIState((prev) => ({ ...prev, ...patch }));
|
||||
const next = await updateUIState(patch);
|
||||
setUIState((prev) => ({ ...prev, ...next }));
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
return { uiState, setUIState, patchUIState, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
+36
-336
@@ -1,341 +1,41 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 222 47% 7%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222 47% 10%;
|
||||
--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 {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: #181825;
|
||||
border-right: 1px solid #313244;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
Reference in New Issue
Block a user