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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user