feat: add dashboard api clients

This commit is contained in:
MythEclipse
2026-05-14 20:46:21 +07:00
parent c3f2d01e9b
commit 2d56012bf5
4 changed files with 413 additions and 18 deletions

View File

@@ -1,8 +1,127 @@
import { useEffect, useRef, useState } from "react";
import { listMessages } from "./api/client";
import type { DashboardMessage } from "./api/client";
import { connectDashboardSocket } from "./ws/client";
import type { DashboardEvent } from "./ws/client";
interface MessageItem {
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 [wsStatus, setWsStatus] = useState<string>("connecting");
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
let cancelled = false;
listMessages(new URLSearchParams({ limit: "30" }))
.then((result) => {
if (!cancelled) {
setMessages(result.data);
}
})
.catch((err) => {
if (!cancelled) {
console.error("Failed to load messages:", err);
}
});
const ws = connectDashboardSocket((event: DashboardEvent) => {
switch (event.type) {
case "message_created":
setMessages((prev) => [event.data, ...prev].slice(0, 200));
break;
case "message_analyzed":
setMessages((prev) =>
prev.map((m) => (m.id === event.data.id ? event.data : m)),
);
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;
}
});
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;
};
}, []);
return ( return (
<div className="app"> <div className="app">
<h1>Discord Moderation Dashboard</h1> <div className="sidebar">
<p>React/Vite scaffold - dashboard components coming soon</p> <div className="sidebar-header">Moderation</div>
<div className="sidebar-placeholder">Channels placeholder</div>
</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">
<div className="message-list">
{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 className="review-panel">
<div className="review-placeholder">Review placeholder</div>
</div>
</div>
</div> </div>
); );
} }

101
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,101 @@
export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error";
export interface MessageRecord {
id: string;
guild_id: string;
channel_id: string;
thread_id: string | null;
user_id: string;
username: string;
avatar_url: string | null;
content: string;
edited_content: string | null;
created_at: number;
edited_at: number | null;
deleted_at: number | null;
type: "text" | "edited" | "deleted";
metadata: string | null;
ai_status?: AIStatus | null;
ai_moderation_flags?: string | null;
ai_moderation_score?: number | null;
ai_moderation_raw?: string | null;
ai_analysis?: string | null;
ai_analyzed_at?: number | null;
ai_error?: string | null;
}
export interface PageResult<T> {
data: T[];
nextCursor: string | null;
}
export interface DashboardMessage {
id: string;
channel_id: string;
user_id: string;
username: string;
avatar_url: string | null;
content: string;
created_at: number;
type: "text" | "image" | "voice";
}
export interface Guild {
id: string;
name: string;
icon: string | null;
}
class ApiError extends Error {
code: string;
statusCode: number;
constructor(code: string, message: string, statusCode: number) {
super(message);
this.name = "ApiError";
this.code = code;
this.statusCode = statusCode;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...init,
});
if (!res.ok) {
let message = res.statusText;
let code = "REQUEST_FAILED";
try {
const body = (await res.json()) as { error?: string; message?: string };
if (body.message) message = body.message;
if (body.error) code = body.error;
} catch {
// ignore parse errors
}
throw new ApiError(code, message, res.status);
}
return res.json() as Promise<T>;
}
export async function listMessages(
params: URLSearchParams,
): Promise<PageResult<MessageRecord>> {
return request<PageResult<MessageRecord>>(`/api/messages?${params}`);
}
export async function listReview(
params: URLSearchParams,
): Promise<PageResult<MessageRecord>> {
return request<PageResult<MessageRecord>>(`/api/review?${params}`);
}
export async function reanalyzeMessage(id: string): Promise<void> {
await request<void>(`/api/messages/${id}/reanalyze`, { method: "POST" });
}
export async function getGuilds(): Promise<Guild[]> {
return request<Guild[]>("/api/guilds");
}

View File

@@ -1,30 +1,173 @@
* { * {
box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", background: #1e1e2e;
sans-serif; color: #cdd6f4;
-webkit-font-smoothing: antialiased; height: 100vh;
-moz-osx-font-smoothing: grayscale; overflow: hidden;
background-color: #1e1e1e; }
color: #e0e0e0;
#root {
height: 100%;
} }
.app { .app {
padding: 2rem; display: flex;
max-width: 1200px; height: 100vh;
margin: 0 auto;
} }
h1 { .sidebar {
margin-bottom: 1rem; width: 220px;
color: #ffffff; background: #181825;
border-right: 1px solid #313244;
display: flex;
flex-direction: column;
flex-shrink: 0;
} }
p { .sidebar-header {
color: #b0b0b0; 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;
}
.review-placeholder {
padding: 16px;
font-size: 12px;
color: #6c7086;
}

32
frontend/src/ws/client.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { MessageRecord } from "../api/client";
export type DashboardEvent =
| { type: "message_created"; data: MessageRecord }
| { type: "message_updated"; data: Partial<MessageRecord> & { id: string } }
| { type: "message_deleted"; data: { id: string; deleted_at: number } }
| { type: "message_analyzed"; data: MessageRecord }
| { type: "analysis_queue_status"; data: unknown }
| { type: "ui_state"; state: unknown }
| { type: "user_state"; users: unknown[] };
export function connectDashboardSocket(
onEvent: (event: DashboardEvent) => void,
): WebSocket {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${protocol}//${window.location.host}/ws`;
const ws = new WebSocket(url);
ws.addEventListener("message", (evt) => {
if (typeof evt.data === "string") {
try {
const event = JSON.parse(evt.data) as DashboardEvent;
onEvent(event);
} catch {
// ignore malformed JSON
}
}
// Binary frames (PCM audio) are ignored for now
});
return ws;
}