feat: add moderation review dashboard

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-05-14 21:01:57 +07:00
parent fbf0f11db1
commit ae02556f75
5 changed files with 333 additions and 44 deletions

View File

@@ -1,21 +1,12 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { listMessages } from "./api/client"; import { listMessages, reanalyzeMessage } from "./api/client";
import { connectDashboardSocket } from "./ws/client"; import { connectDashboardSocket } from "./ws/client";
import type { DashboardEvent } from "./ws/client"; import type { DashboardEvent, MessageRecord } from "./api/client";
import { MessageFeed } from "./components/messages/MessageFeed";
interface MessageItem { import { ReviewPanel } from "./components/review/ReviewPanel";
id: string;
channel_id: string;
user_id: string;
username: string;
avatar_url: string | null;
content: string;
created_at: number;
type: "text" | "edited" | "deleted";
}
export default function App() { export default function App() {
const [messages, setMessages] = useState<MessageItem[]>([]); const [messages, setMessages] = useState<MessageRecord[]>([]);
const [wsStatus, setWsStatus] = useState<string>("connecting"); const [wsStatus, setWsStatus] = useState<string>("connecting");
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@@ -72,6 +63,29 @@ export default function App() {
}; };
}, []); }, []);
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,
),
);
}
};
return ( return (
<div className="app"> <div className="app">
<div className="sidebar"> <div className="sidebar">
@@ -88,38 +102,10 @@ export default function App() {
</div> </div>
<div className="content"> <div className="content">
<div className="message-list"> <MessageFeed messages={messages} onReanalyze={handleReanalyze} />
{messages.length === 0 ? (
<p className="empty-state">No messages yet</p>
) : (
messages.map((msg) => (
<div key={msg.id} className={`message-item type-${msg.type}`}>
<img
src={msg.avatar_url ?? "/default-avatar.png"}
alt={msg.username}
className="message-avatar"
width={32}
height={32}
/>
<div className="message-body">
<span className="message-username">{msg.username}</span>
<span className="message-time">
{new Date(msg.created_at).toLocaleString()}
</span>
{msg.type === "deleted" && (
<span className="message-deleted">[deleted]</span>
)}
<p className="message-content">{msg.content}</p>
</div>
</div>
))
)}
</div>
</div> </div>
<div className="review-panel"> <ReviewPanel messages={messages} onReanalyze={handleReanalyze} />
<div className="review-placeholder">Review placeholder</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,73 @@
import type { MessageRecord } from "../../api/client";
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",
};
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
className="btn-reanalyze"
onClick={() => onReanalyze(message.id)}
disabled={aiStatus === "pending"}
>
Reanalyze
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import type { MessageRecord } from "../../api/client";
import { MessageCard } from "./MessageCard";
export interface MessageFeedProps {
messages: MessageRecord[];
onReanalyze: (id: string) => void;
}
export function MessageFeed({ messages, onReanalyze }: MessageFeedProps) {
if (messages.length === 0) {
return (
<div className="empty-state">
<p>No messages yet</p>
</div>
);
}
return (
<div className="message-feed">
{messages.map((msg) => (
<MessageCard key={msg.id} message={msg} onReanalyze={onReanalyze} />
))}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import type { MessageRecord } from "../../api/client";
import { MessageCard } from "../messages/MessageCard";
export interface ReviewPanelProps {
messages: MessageRecord[];
onReanalyze: (id: string) => void;
}
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
const reviewItems = messages.filter(
(m) =>
m.ai_status === "warn" ||
m.ai_status === "flagged" ||
m.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>
);
}

View File

@@ -166,6 +166,174 @@ body {
flex-shrink: 0; 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 { .review-placeholder {
padding: 16px; padding: 16px;
font-size: 12px; font-size: 12px;