diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c4c86da..599f495 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { + const [messages, setMessages] = useState([]); + const [wsStatus, setWsStatus] = useState("connecting"); + const wsRef = useRef(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 (
-

Discord Moderation Dashboard

-

React/Vite scaffold - dashboard components coming soon

+
+
Moderation
+
Channels placeholder
+
+ +
+
+

Discord Moderation Dashboard

+ + {wsStatus} + +
+ +
+
+ {messages.length === 0 ? ( +

No messages yet

+ ) : ( + messages.map((msg) => ( +
+ {msg.username} +
+ {msg.username} + + {new Date(msg.created_at).toLocaleString()} + + {msg.type === "deleted" && ( + [deleted] + )} +

{msg.content}

+
+
+ )) + )} +
+
+ +
+
Review placeholder
+
+
); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..3b9d4dd --- /dev/null +++ b/frontend/src/api/client.ts @@ -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 { + 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(path: string, init?: RequestInit): Promise { + 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; +} + +export async function listMessages( + params: URLSearchParams, +): Promise> { + return request>(`/api/messages?${params}`); +} + +export async function listReview( + params: URLSearchParams, +): Promise> { + return request>(`/api/review?${params}`); +} + +export async function reanalyzeMessage(id: string): Promise { + await request(`/api/messages/${id}/reanalyze`, { method: "POST" }); +} + +export async function getGuilds(): Promise { + return request("/api/guilds"); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4a266bd..01bd8b3 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,30 +1,173 @@ * { + box-sizing: border-box; margin: 0; padding: 0; - box-sizing: border-box; } body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", - "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - background-color: #1e1e1e; - color: #e0e0e0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #1e1e2e; + color: #cdd6f4; + height: 100vh; + overflow: hidden; +} + +#root { + height: 100%; } .app { - padding: 2rem; - max-width: 1200px; - margin: 0 auto; + display: flex; + height: 100vh; } -h1 { - margin-bottom: 1rem; - color: #ffffff; +.sidebar { + width: 220px; + background: #181825; + border-right: 1px solid #313244; + display: flex; + flex-direction: column; + flex-shrink: 0; } -p { - color: #b0b0b0; +.sidebar-header { + 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; +} \ No newline at end of file diff --git a/frontend/src/ws/client.ts b/frontend/src/ws/client.ts new file mode 100644 index 0000000..aa44679 --- /dev/null +++ b/frontend/src/ws/client.ts @@ -0,0 +1,32 @@ +import type { MessageRecord } from "../api/client"; + +export type DashboardEvent = + | { type: "message_created"; data: MessageRecord } + | { type: "message_updated"; data: Partial & { 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; +} \ No newline at end of file