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:
MythEclipse
2026-05-16 19:17:34 +07:00
co-authored by Claude Opus 4.7
parent 3c7d722973
commit 82025a19b2
51 changed files with 2865 additions and 1204 deletions
@@ -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>
);
}
+40
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>
);
}
@@ -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>
);
}
+17 -25
View File
@@ -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>
);
}
}
+30
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}
/>
);
}
+48
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}
/>
);
}
+26
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} />;
}
+15
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}
/>
);
}
@@ -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>
);
}
+31
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>
);
}
+35
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}
/>
);
}
@@ -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>
);
}