feat: add moderation review dashboard
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
73
frontend/src/components/messages/MessageCard.tsx
Normal file
73
frontend/src/components/messages/MessageCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/components/messages/MessageFeed.tsx
Normal file
25
frontend/src/components/messages/MessageFeed.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/src/components/review/ReviewPanel.tsx
Normal file
37
frontend/src/components/review/ReviewPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user