diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 443d667..e7a32a5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -26,14 +26,11 @@ export interface MessageRecord { ai_status?: AIStatus | null; ai_moderation_flags?: string | null; ai_moderation_score?: number | null; - ai_moderation_raw?: string | null; ai_analysis?: string | null; ai_categories?: string | null; ai_severity?: AISeverity | null; ai_confidence?: number | null; ai_recommended_action?: AIRecommendedAction | null; - ai_policy_version?: string | null; - ai_evidence?: string | null; ai_analyzed_at?: number | null; ai_error?: string | null; } diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 649cb1a..93e27cd 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,4 +1,4 @@ -import { Wifi, WifiOff } from "lucide-react"; +import { Wifi, WifiOff, Shield } from "lucide-react"; import type { WebSocketStatus } from "../../hooks/useDashboardSocket"; import type { DashboardTab } from "../../types/ui"; import type { VoiceStatus } from "../../types/voice"; @@ -7,12 +7,21 @@ import { Badge } from "../ui/badge"; const titles: Record = { voice: "Voice Control", media: "Media Player", - messages: "Messages", + messages: "Messages & Moderation", recordings: "Voice Recordings", analytics: "Analytics & Insights", review: "Moderation Review", }; +const subtitles: Record = { + voice: "Join voice channels and stream audio.", + media: "Queue music, videos, and screen share.", + messages: "Capture, analyse, and moderate Discord messages.", + recordings: "Browse recorded voice segments.", + analytics: "Server moderation statistics and trends.", + review: "Review AI-flagged messages for moderation.", +}; + interface HeaderProps { activeTab: DashboardTab; wsStatus: WebSocketStatus; @@ -23,9 +32,14 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) { return (
-
-

{titles[activeTab]}

-

Voice, media, and moderation in one dashboard.

+
+
+ +
+
+

{titles[activeTab]}

+

{subtitles[activeTab]}

+
diff --git a/frontend/src/components/messages/ImageGrid.tsx b/frontend/src/components/messages/ImageGrid.tsx index edceb86..7215965 100644 --- a/frontend/src/components/messages/ImageGrid.tsx +++ b/frontend/src/components/messages/ImageGrid.tsx @@ -9,19 +9,40 @@ function parseMetadata(value: string | null): MessageMetadata { } } +interface ImageItem { + url: string; + title: string; + kind: "attachment" | "embed" | "sticker"; + message: MessageRecord; +} + export function ImageGrid({ messages }: { messages: MessageRecord[] }) { - const images = messages.flatMap((message) => { + const images: ImageItem[] = []; + + for (const message of messages) { 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 }))), - ]; - }); + + // Stickers + for (const sticker of metadata.stickers ?? []) { + if (sticker.url) { + images.push({ url: sticker.url, title: sticker.name || "sticker", kind: "sticker", message }); + } + } + + // Attachments + for (const attachment of metadata.attachments ?? []) { + if (attachment.url && (attachment.contentType?.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(attachment.name))) { + images.push({ url: attachment.url, title: attachment.name, kind: "attachment", message }); + } + } + + // Embed images + for (const embed of metadata.embeds ?? []) { + for (const imgUrl of [embed.image, embed.thumbnail].filter(Boolean)) { + images.push({ url: imgUrl as string, title: embed.title || "embed image", kind: "embed", message }); + } + } + } if (images.length === 0) { return
No images found.
; @@ -30,11 +51,45 @@ export function ImageGrid({ messages }: { messages: MessageRecord[] }) { return (
{images.map((image, index) => ( - - {image.title} + +
+ {image.kind === "sticker" ? ( + {image.title} + ) : ( + {image.title} + )} +
+ {image.kind} +
+
{image.title}
-
{image.message.username}
+
+
+ +
+ {image.message.username} +
))} diff --git a/frontend/src/components/messages/MessageCard.tsx b/frontend/src/components/messages/MessageCard.tsx index 1599eff..621eef2 100644 --- a/frontend/src/components/messages/MessageCard.tsx +++ b/frontend/src/components/messages/MessageCard.tsx @@ -1,27 +1,21 @@ -import { RotateCw, AlertCircle, CheckCircle2, AlertTriangle } from "lucide-react"; -import type { MessageRecord } from "../../types/messages"; +import { RotateCw, AlertCircle, CheckCircle2, AlertTriangle, Trash2, Pencil, Image as ImageIcon, Smile } from "lucide-react"; +import type { MessageMetadata, MessageRecord } from "../../types/messages"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; -import { useState } from "react"; +import { useState, useMemo } from "react"; export interface MessageCardProps { message: MessageRecord; onReanalyze: (id: string) => void; } -function aiVariant(status: string) { - if (status === "clean") return "success"; - if (status === "warn") return "warning"; - if (status === "flagged" || status === "error") return "destructive"; - return "secondary"; -} - -function getAiIcon(status: string) { - if (status === "clean") return ; - if (status === "warn") return ; - if (status === "flagged") return ; - if (status === "error") return ; - return null; +function parseMetadata(value: string | null): MessageMetadata { + if (!value) return {}; + try { + return JSON.parse(value) as MessageMetadata; + } catch { + return {}; + } } function parseStringList(value?: string | null): string[] { @@ -37,14 +31,57 @@ function parseStringList(value?: string | null): string[] { } } +function aiVariant(status: string) { + if (status === "clean") return "success"; + if (status === "warn") return "warning"; + if (status === "flagged" || status === "error") return "destructive"; + return "secondary"; +} + +function getAiIcon(status: string) { + if (status === "clean") return ; + if (status === "warn") return ; + if (status === "flagged") return ; + if (status === "error") return ; + return null; +} + +function severityColor(severity: string) { + switch (severity) { + case "critical": return "bg-red-500/20 text-red-300 border-red-500/30"; + case "high": return "bg-orange-500/20 text-orange-300 border-orange-500/30"; + case "medium": return "bg-yellow-500/20 text-yellow-300 border-yellow-500/30"; + case "low": return "bg-blue-500/20 text-blue-300 border-blue-500/30"; + default: return "bg-muted text-muted-foreground border-border"; + } +} + +function formatTimeAgo(ts: number): string { + const seconds = Math.floor((Date.now() - ts) / 1000); + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return new Date(ts).toLocaleDateString(); +} + export function MessageCard({ message, onReanalyze }: MessageCardProps) { + const metadata = useMemo(() => parseMetadata(message.metadata), [message.metadata]); const displayContent = message.edited_content ?? message.content; const aiStatus = message.ai_status ?? "pending"; - const categories = parseStringList(message.ai_categories ?? message.ai_moderation_flags); - const evidence = parseStringList(message.ai_evidence); + const categories = useMemo(() => { + const list = parseStringList(message.ai_categories ?? message.ai_moderation_flags); + return list.filter((c) => c !== "analysis_incomplete"); + }, [message.ai_categories, message.ai_moderation_flags]); const confidence = message.ai_confidence ?? message.ai_moderation_score ?? null; const [isReanalyzing, setIsReanalyzing] = useState(false); + const stickers = metadata.stickers ?? []; + const attachments = metadata.attachments ?? []; + const imageAttachments = attachments.filter( + (a) => a.contentType?.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(a.name), + ); + const hasImages = imageAttachments.length > 0; + const handleReanalyze = async () => { setIsReanalyzing(true); try { @@ -55,70 +92,144 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) { }; return ( -
+
-
-
- {message.username || message.user_id} - - {new Date(message.created_at).toLocaleString()} +
+ {/* Header row */} +
+ {message.username || message.user_id} + + {formatTimeAgo(message.created_at)} - {message.edited_at ? edited : null} - {message.deleted_at ? deleted : null} - - {getAiIcon(aiStatus)} - {aiStatus} - -
-

- {displayContent || "(empty message)"} -

-
- {message.ai_severity ? severity: {message.ai_severity} : null} - {message.ai_recommended_action ? action: {message.ai_recommended_action} : null} - {confidence != null ? confidence: {Math.round(confidence * 100)}% : null} - {message.ai_policy_version ? policy: {message.ai_policy_version} : null} - {categories.slice(0, 6).map((category) => ( - {category} - ))} + {message.edited_at && ( + + edited + + )} + {message.deleted_at && ( + + deleted + + )} +
+ + {getAiIcon(aiStatus)} + {aiStatus} + + {message.ai_severity && message.ai_severity !== "none" && ( + + {message.ai_severity} + + )} + {confidence != null && ( + + {Math.round(confidence * 100)}% + + )} +
+ + {/* Content */} + {displayContent ? ( +

+ {displayContent} +

+ ) : null} + + {/* Sticker preview */} + {stickers.length > 0 && ( +
+ {stickers.map((sticker) => ( +
+ {sticker.url ? ( + {sticker.name + ) : ( +
+ +
+ )} + + {sticker.name} + +
+ ))} +
+ )} + + {/* Image thumbnails */} + {hasImages && ( +
+ {imageAttachments.slice(0, 4).map((img) => ( + + {img.name} + + ))} + {imageAttachments.length > 4 && ( +
+ +{imageAttachments.length - 4} +
+ )} +
+ )} + + {/* Categories / flags */} + {categories.length > 0 && ( +
+ {categories.map((category) => ( + {category} + ))} +
+ )} + + {/* AI analysis text */} {message.ai_analysis ? ( -
+
{message.ai_analysis}
) : null} - {evidence.length > 0 ? ( -
-
Evidence
-
    - {evidence.slice(0, 4).map((item, index) => ( -
  • {item}
  • - ))} -
-
- ) : null} + + {/* AI error */} {message.ai_error ? (
AI error: {message.ai_error}
) : null} -
+ + {/* Actions */} +
{aiStatus === "error" && ( - + Click to retry analysis )} diff --git a/frontend/src/components/messages/MessagesPanel.tsx b/frontend/src/components/messages/MessagesPanel.tsx index e7e13c5..603d1fd 100644 --- a/frontend/src/components/messages/MessagesPanel.tsx +++ b/frontend/src/components/messages/MessagesPanel.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import type { Channel, Guild } from "../../types/voice"; import type { MessageRecord } from "../../types/messages"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; @@ -8,6 +8,8 @@ import { ImageGrid } from "./ImageGrid"; import { MessageFeed } from "./MessageFeed"; import { Input } from "../ui/input"; import { Button } from "../ui/button"; +import { Badge } from "../ui/badge"; +import { Search, X, Filter } from "lucide-react"; interface MessagesPanelProps { guilds: Guild[]; @@ -20,6 +22,8 @@ interface MessagesPanelProps { onReanalyze: (id: string) => void; } +type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending"; + export function MessagesPanel({ guilds, channels, @@ -34,10 +38,13 @@ export function MessagesPanel({ const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const [showSearch, setShowSearch] = useState(false); + const [aiFilter, setAiFilter] = useState("all"); + const [viewTab, setViewTab] = useState<"all" | "images">("all"); const handleSearch = async () => { if (!searchQuery.trim()) { setSearchResults([]); + setShowSearch(false); return; } @@ -54,6 +61,7 @@ export function MessagesPanel({ const data = await response.json(); setSearchResults(data.results || []); + setShowSearch(true); } catch (error) { console.error("Search error:", error); setSearchResults([]); @@ -62,10 +70,33 @@ export function MessagesPanel({ } }; - const displayMessages = showSearch ? searchResults : messages; + const stats = useMemo(() => { + const base = showSearch ? searchResults : messages; + return { + total: base.length, + clean: base.filter((m) => m.ai_status === "clean").length, + warn: base.filter((m) => m.ai_status === "warn").length, + flagged: base.filter((m) => m.ai_status === "flagged").length, + error: base.filter((m) => m.ai_status === "error").length, + pending: base.filter((m) => m.ai_status === "pending" || !m.ai_status).length, + deleted: base.filter((m) => m.deleted_at).length, + edited: base.filter((m) => m.edited_at).length, + }; + }, [messages, searchResults, showSearch]); + + const filteredMessages = useMemo(() => { + const base = showSearch ? searchResults : messages; + if (aiFilter === "all") return base; + return base.filter((m) => { + const status = m.ai_status ?? "pending"; + if (aiFilter === "pending") return status === "pending" || status === null || status === undefined; + return status === aiFilter; + }); + }, [messages, searchResults, showSearch, aiFilter]); return (
+ {/* Source selector */} Message Source @@ -87,54 +118,72 @@ export function MessagesPanel({ - - - Search Messages - Search for messages by content - - -
- setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - disabled={isSearching} - /> - - {showSearch && ( - - )} -
- {showSearch && searchResults.length > 0 && ( -
- Found {searchResults.length} result{searchResults.length !== 1 ? "s" : ""} -
- )} -
-
+ {/* Stats bar */} + {stats.total > 0 && ( +
+ {stats.total} total + {stats.clean} clean + {stats.warn} warn + {stats.flagged} flagged + {stats.error} error + {stats.pending} pending + {stats.deleted > 0 && {stats.deleted} deleted} + {stats.edited > 0 && {stats.edited} edited} +
+ )} - + {/* Search + Filter row */} +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + disabled={isSearching} + /> +
+ + {showSearch && ( + + )} +
+ + {(["all", "clean", "warn", "flagged", "error", "pending"] as AiFilter[]).map((f) => ( + + ))} +
+
+ + {showSearch && searchResults.length > 0 && ( +
+ Found {searchResults.length} result{searchResults.length !== 1 ? "s" : ""} +
+ )} + + {/* View tabs */} + setViewTab(v as "all" | "images")}> - {showSearch ? "Search Results" : "All Messages"} + {showSearch ? `Search (${filteredMessages.length})` : `All (${filteredMessages.length})`} Images - +