feat: redesign dashboard messages UI with better sticker/image display and AI filtering
MessageCard:
- Add sticker image preview (renders actual sticker from URL, not just text name)
- Show attachment thumbnails inline (up to 4 with overflow counter)
- Remove dead columns (ai_policy_version, ai_evidence) from UI
- Add severity color coding (critical=red, high=orange, medium=yellow, low=blue)
- Add relative time display ('2h ago' instead of full datetime)
- Better hover effects and visual hierarchy
MessagesPanel:
- Add stats bar showing total/clean/warn/flagged/error/pending/deleted/edited counts
- Add AI status filter buttons (all, clean, warn, flagged, error, pending)
- Improve search UX with inline search icon and clear button
- Show filtered count in tab labels
ImageGrid:
- Include sticker images (was only attachments + embeds before)
- Add kind badge overlay (sticker/attachment/embed)
- Show user avatar next to image caption
- Better sticker rendering (object-contain with padding)
Header:
- Add shield icon per tab
- Improve tab titles and add descriptive subtitles
API client:
- Remove dead fields from MessageRecord (ai_moderation_raw, ai_policy_version, ai_evidence)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a00a5a508f
commit
c213300800
@@ -26,14 +26,11 @@ export interface MessageRecord {
|
|||||||
ai_status?: AIStatus | null;
|
ai_status?: AIStatus | null;
|
||||||
ai_moderation_flags?: string | null;
|
ai_moderation_flags?: string | null;
|
||||||
ai_moderation_score?: number | null;
|
ai_moderation_score?: number | null;
|
||||||
ai_moderation_raw?: string | null;
|
|
||||||
ai_analysis?: string | null;
|
ai_analysis?: string | null;
|
||||||
ai_categories?: string | null;
|
ai_categories?: string | null;
|
||||||
ai_severity?: AISeverity | null;
|
ai_severity?: AISeverity | null;
|
||||||
ai_confidence?: number | null;
|
ai_confidence?: number | null;
|
||||||
ai_recommended_action?: AIRecommendedAction | null;
|
ai_recommended_action?: AIRecommendedAction | null;
|
||||||
ai_policy_version?: string | null;
|
|
||||||
ai_evidence?: string | null;
|
|
||||||
ai_analyzed_at?: number | null;
|
ai_analyzed_at?: number | null;
|
||||||
ai_error?: string | null;
|
ai_error?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { WebSocketStatus } from "../../hooks/useDashboardSocket";
|
||||||
import type { DashboardTab } from "../../types/ui";
|
import type { DashboardTab } from "../../types/ui";
|
||||||
import type { VoiceStatus } from "../../types/voice";
|
import type { VoiceStatus } from "../../types/voice";
|
||||||
@@ -7,12 +7,21 @@ import { Badge } from "../ui/badge";
|
|||||||
const titles: Record<DashboardTab, string> = {
|
const titles: Record<DashboardTab, string> = {
|
||||||
voice: "Voice Control",
|
voice: "Voice Control",
|
||||||
media: "Media Player",
|
media: "Media Player",
|
||||||
messages: "Messages",
|
messages: "Messages & Moderation",
|
||||||
recordings: "Voice Recordings",
|
recordings: "Voice Recordings",
|
||||||
analytics: "Analytics & Insights",
|
analytics: "Analytics & Insights",
|
||||||
review: "Moderation Review",
|
review: "Moderation Review",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const subtitles: Record<DashboardTab, string> = {
|
||||||
|
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 {
|
interface HeaderProps {
|
||||||
activeTab: DashboardTab;
|
activeTab: DashboardTab;
|
||||||
wsStatus: WebSocketStatus;
|
wsStatus: WebSocketStatus;
|
||||||
@@ -23,9 +32,14 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
|||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 px-4 py-4 backdrop-blur md:px-8">
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 px-4 py-4 backdrop-blur md:px-8">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{titles[activeTab]}</h1>
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||||||
<p className="text-sm text-muted-foreground">Voice, media, and moderation in one dashboard.</p>
|
<Shield className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold tracking-tight">{titles[activeTab]}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{subtitles[activeTab]}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge variant={wsStatus === "connected" ? "success" : wsStatus === "error" ? "destructive" : "warning"}>
|
<Badge variant={wsStatus === "connected" ? "success" : wsStatus === "error" ? "destructive" : "warning"}>
|
||||||
|
|||||||
@@ -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[] }) {
|
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 metadata = parseMetadata(message.metadata);
|
||||||
const attachments = metadata.attachments ?? [];
|
|
||||||
const embeds = metadata.embeds ?? [];
|
// Stickers
|
||||||
return [
|
for (const sticker of metadata.stickers ?? []) {
|
||||||
...attachments
|
if (sticker.url) {
|
||||||
.filter((attachment) => attachment.url && (attachment.contentType?.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(attachment.name)))
|
images.push({ url: sticker.url, title: sticker.name || "sticker", kind: "sticker", message });
|
||||||
.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 }))),
|
|
||||||
];
|
// 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) {
|
if (images.length === 0) {
|
||||||
return <div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">No images found.</div>;
|
return <div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">No images found.</div>;
|
||||||
@@ -30,11 +51,45 @@ export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
{images.map((image, index) => (
|
{images.map((image, index) => (
|
||||||
<a key={`${image.url}-${index}`} href={image.url} target="_blank" rel="noreferrer" className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
|
<a
|
||||||
<img src={image.url} alt={image.title} className="aspect-video w-full object-cover transition-transform group-hover:scale-105" />
|
key={`${image.url}-${index}`}
|
||||||
|
href={image.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video overflow-hidden">
|
||||||
|
{image.kind === "sticker" ? (
|
||||||
|
<img
|
||||||
|
src={image.url}
|
||||||
|
alt={image.title}
|
||||||
|
className="h-full w-full object-contain bg-muted/30 p-2 transition-transform group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={image.url}
|
||||||
|
alt={image.title}
|
||||||
|
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="absolute right-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-white backdrop-blur">
|
||||||
|
{image.kind}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<div className="truncate text-sm font-medium">{image.title}</div>
|
<div className="truncate text-sm font-medium">{image.title}</div>
|
||||||
<div className="truncate text-xs text-muted-foreground">{image.message.username}</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-4 w-4 overflow-hidden rounded-full">
|
||||||
|
<img
|
||||||
|
src={image.message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{image.message.username}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
import { RotateCw, AlertCircle, CheckCircle2, AlertTriangle } from "lucide-react";
|
import { RotateCw, AlertCircle, CheckCircle2, AlertTriangle, Trash2, Pencil, Image as ImageIcon, Smile } from "lucide-react";
|
||||||
import type { MessageRecord } from "../../types/messages";
|
import type { MessageMetadata, MessageRecord } from "../../types/messages";
|
||||||
import { Badge } from "../ui/badge";
|
import { Badge } from "../ui/badge";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
|
||||||
export interface MessageCardProps {
|
export interface MessageCardProps {
|
||||||
message: MessageRecord;
|
message: MessageRecord;
|
||||||
onReanalyze: (id: string) => void;
|
onReanalyze: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aiVariant(status: string) {
|
function parseMetadata(value: string | null): MessageMetadata {
|
||||||
if (status === "clean") return "success";
|
if (!value) return {};
|
||||||
if (status === "warn") return "warning";
|
try {
|
||||||
if (status === "flagged" || status === "error") return "destructive";
|
return JSON.parse(value) as MessageMetadata;
|
||||||
return "secondary";
|
} catch {
|
||||||
}
|
return {};
|
||||||
|
}
|
||||||
function getAiIcon(status: string) {
|
|
||||||
if (status === "clean") return <CheckCircle2 className="h-4 w-4" />;
|
|
||||||
if (status === "warn") return <AlertTriangle className="h-4 w-4" />;
|
|
||||||
if (status === "flagged") return <AlertCircle className="h-4 w-4" />;
|
|
||||||
if (status === "error") return <AlertCircle className="h-4 w-4" />;
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStringList(value?: string | null): string[] {
|
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 <CheckCircle2 className="h-3.5 w-3.5" />;
|
||||||
|
if (status === "warn") return <AlertTriangle className="h-3.5 w-3.5" />;
|
||||||
|
if (status === "flagged") return <AlertCircle className="h-3.5 w-3.5" />;
|
||||||
|
if (status === "error") return <AlertCircle className="h-3.5 w-3.5" />;
|
||||||
|
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) {
|
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
||||||
|
const metadata = useMemo(() => parseMetadata(message.metadata), [message.metadata]);
|
||||||
const displayContent = message.edited_content ?? message.content;
|
const displayContent = message.edited_content ?? message.content;
|
||||||
const aiStatus = message.ai_status ?? "pending";
|
const aiStatus = message.ai_status ?? "pending";
|
||||||
const categories = parseStringList(message.ai_categories ?? message.ai_moderation_flags);
|
const categories = useMemo(() => {
|
||||||
const evidence = parseStringList(message.ai_evidence);
|
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 confidence = message.ai_confidence ?? message.ai_moderation_score ?? null;
|
||||||
const [isReanalyzing, setIsReanalyzing] = useState(false);
|
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 () => {
|
const handleReanalyze = async () => {
|
||||||
setIsReanalyzing(true);
|
setIsReanalyzing(true);
|
||||||
try {
|
try {
|
||||||
@@ -55,70 +92,144 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
|
<article className={`group rounded-2xl border border-border bg-card p-4 shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${message.deleted_at ? "opacity-60" : ""}`}>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<img
|
<img
|
||||||
src={message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
|
src={message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-10 w-10 rounded-full object-cover"
|
className="h-10 w-10 shrink-0 rounded-full object-cover ring-1 ring-border"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1 space-y-3">
|
<div className="min-w-0 flex-1 space-y-2.5">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{/* Header row */}
|
||||||
<span className="font-medium">{message.username || message.user_id}</span>
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="font-semibold text-foreground">{message.username || message.user_id}</span>
|
||||||
{new Date(message.created_at).toLocaleString()}
|
<span className="text-xs text-muted-foreground" title={new Date(message.created_at).toLocaleString()}>
|
||||||
|
{formatTimeAgo(message.created_at)}
|
||||||
</span>
|
</span>
|
||||||
{message.edited_at ? <Badge variant="outline">edited</Badge> : null}
|
{message.edited_at && (
|
||||||
{message.deleted_at ? <Badge variant="destructive">deleted</Badge> : null}
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Badge variant={aiVariant(aiStatus)} className="flex items-center gap-1">
|
<Pencil className="h-3 w-3" /> edited
|
||||||
{getAiIcon(aiStatus)}
|
</span>
|
||||||
{aiStatus}
|
)}
|
||||||
</Badge>
|
{message.deleted_at && (
|
||||||
</div>
|
<span className="flex items-center gap-1 text-xs text-destructive">
|
||||||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
|
<Trash2 className="h-3 w-3" /> deleted
|
||||||
{displayContent || "(empty message)"}
|
</span>
|
||||||
</p>
|
)}
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
{message.ai_severity ? <Badge variant="outline">severity: {message.ai_severity}</Badge> : null}
|
<Badge variant={aiVariant(aiStatus)} className="flex items-center gap-1 text-xs">
|
||||||
{message.ai_recommended_action ? <Badge variant="outline">action: {message.ai_recommended_action}</Badge> : null}
|
{getAiIcon(aiStatus)}
|
||||||
{confidence != null ? <Badge variant="outline">confidence: {Math.round(confidence * 100)}%</Badge> : null}
|
{aiStatus}
|
||||||
{message.ai_policy_version ? <Badge variant="outline">policy: {message.ai_policy_version}</Badge> : null}
|
</Badge>
|
||||||
{categories.slice(0, 6).map((category) => (
|
{message.ai_severity && message.ai_severity !== "none" && (
|
||||||
<Badge key={category} variant="secondary">{category}</Badge>
|
<Badge className={`text-xs ${severityColor(message.ai_severity)}`}>
|
||||||
))}
|
{message.ai_severity}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{confidence != null && (
|
||||||
|
<Badge variant="outline" className="text-xs tabular-nums">
|
||||||
|
{Math.round(confidence * 100)}%
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{displayContent ? (
|
||||||
|
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
|
||||||
|
{displayContent}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Sticker preview */}
|
||||||
|
{stickers.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{stickers.map((sticker) => (
|
||||||
|
<div key={sticker.name || sticker.url} className="flex items-center gap-2">
|
||||||
|
{sticker.url ? (
|
||||||
|
<img
|
||||||
|
src={sticker.url}
|
||||||
|
alt={sticker.name || "sticker"}
|
||||||
|
className="h-16 w-16 rounded-xl border border-border object-contain bg-muted/50"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-xl border border-border bg-muted/50">
|
||||||
|
<Smile className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground max-w-[120px] truncate" title={sticker.name}>
|
||||||
|
{sticker.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image thumbnails */}
|
||||||
|
{hasImages && (
|
||||||
|
<div className="flex gap-2 overflow-x-auto">
|
||||||
|
{imageAttachments.slice(0, 4).map((img) => (
|
||||||
|
<a
|
||||||
|
key={img.url}
|
||||||
|
href={img.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="shrink-0 overflow-hidden rounded-xl border border-border"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={img.url}
|
||||||
|
alt={img.name}
|
||||||
|
className="h-20 w-20 object-cover transition-transform hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{imageAttachments.length > 4 && (
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-xl border border-border bg-muted text-xs text-muted-foreground">
|
||||||
|
+{imageAttachments.length - 4} <ImageIcon className="ml-1 h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Categories / flags */}
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<Badge key={category} variant="secondary" className="text-xs">{category}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI analysis text */}
|
||||||
{message.ai_analysis ? (
|
{message.ai_analysis ? (
|
||||||
<div className="rounded-xl bg-muted p-3 text-sm text-muted-foreground">
|
<div className="rounded-xl bg-muted/60 p-3 text-sm text-muted-foreground leading-relaxed">
|
||||||
{message.ai_analysis}
|
{message.ai_analysis}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{evidence.length > 0 ? (
|
|
||||||
<div className="rounded-xl border border-border bg-background/50 p-3 text-xs text-muted-foreground">
|
{/* AI error */}
|
||||||
<div className="mb-1 font-medium text-foreground/80">Evidence</div>
|
|
||||||
<ul className="list-disc space-y-1 pl-4">
|
|
||||||
{evidence.slice(0, 4).map((item, index) => (
|
|
||||||
<li key={`${message.id}-evidence-${index}`}>{item}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{message.ai_error ? (
|
{message.ai_error ? (
|
||||||
<div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">
|
<div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
AI error: {message.ai_error}
|
AI error: {message.ai_error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex gap-2">
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={aiStatus === "error" ? "destructive" : "outline"}
|
variant={aiStatus === "error" ? "destructive" : "outline"}
|
||||||
onClick={handleReanalyze}
|
onClick={handleReanalyze}
|
||||||
disabled={aiStatus === "pending" || isReanalyzing}
|
disabled={aiStatus === "pending" || isReanalyzing}
|
||||||
|
className="text-xs"
|
||||||
>
|
>
|
||||||
<RotateCw className={`h-3.5 w-3.5 ${isReanalyzing ? "animate-spin" : ""}`} />
|
<RotateCw className={`h-3.5 w-3.5 ${isReanalyzing ? "animate-spin" : ""}`} />
|
||||||
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
||||||
</Button>
|
</Button>
|
||||||
{aiStatus === "error" && (
|
{aiStatus === "error" && (
|
||||||
<span className="text-xs text-destructive self-center">
|
<span className="text-xs text-destructive/80">
|
||||||
Click to retry analysis
|
Click to retry analysis
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import type { Channel, Guild } from "../../types/voice";
|
import type { Channel, Guild } from "../../types/voice";
|
||||||
import type { MessageRecord } from "../../types/messages";
|
import type { MessageRecord } from "../../types/messages";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
@@ -8,6 +8,8 @@ import { ImageGrid } from "./ImageGrid";
|
|||||||
import { MessageFeed } from "./MessageFeed";
|
import { MessageFeed } from "./MessageFeed";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Search, X, Filter } from "lucide-react";
|
||||||
|
|
||||||
interface MessagesPanelProps {
|
interface MessagesPanelProps {
|
||||||
guilds: Guild[];
|
guilds: Guild[];
|
||||||
@@ -20,6 +22,8 @@ interface MessagesPanelProps {
|
|||||||
onReanalyze: (id: string) => void;
|
onReanalyze: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
|
||||||
|
|
||||||
export function MessagesPanel({
|
export function MessagesPanel({
|
||||||
guilds,
|
guilds,
|
||||||
channels,
|
channels,
|
||||||
@@ -34,10 +38,13 @@ export function MessagesPanel({
|
|||||||
const [searchResults, setSearchResults] = useState<MessageRecord[]>([]);
|
const [searchResults, setSearchResults] = useState<MessageRecord[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
const [aiFilter, setAiFilter] = useState<AiFilter>("all");
|
||||||
|
const [viewTab, setViewTab] = useState<"all" | "images">("all");
|
||||||
|
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
|
setShowSearch(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +61,7 @@ export function MessagesPanel({
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setSearchResults(data.results || []);
|
setSearchResults(data.results || []);
|
||||||
|
setShowSearch(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Search error:", error);
|
console.error("Search error:", error);
|
||||||
setSearchResults([]);
|
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 (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
|
{/* Source selector */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Message Source</CardTitle>
|
<CardTitle>Message Source</CardTitle>
|
||||||
@@ -87,54 +118,72 @@ export function MessagesPanel({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
{/* Stats bar */}
|
||||||
<CardHeader>
|
{stats.total > 0 && (
|
||||||
<CardTitle>Search Messages</CardTitle>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<CardDescription>Search for messages by content</CardDescription>
|
<Badge variant="secondary" className="text-xs">{stats.total} total</Badge>
|
||||||
</CardHeader>
|
<Badge variant="outline" className="text-xs text-green-400 border-green-400/30">{stats.clean} clean</Badge>
|
||||||
<CardContent className="grid gap-4">
|
<Badge variant="outline" className="text-xs text-yellow-400 border-yellow-400/30">{stats.warn} warn</Badge>
|
||||||
<div className="flex gap-2">
|
<Badge variant="outline" className="text-xs text-red-400 border-red-400/30">{stats.flagged} flagged</Badge>
|
||||||
<Input
|
<Badge variant="outline" className="text-xs text-orange-400 border-orange-400/30">{stats.error} error</Badge>
|
||||||
placeholder="Search message content..."
|
<Badge variant="outline" className="text-xs">{stats.pending} pending</Badge>
|
||||||
value={searchQuery}
|
{stats.deleted > 0 && <Badge variant="destructive" className="text-xs">{stats.deleted} deleted</Badge>}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
{stats.edited > 0 && <Badge variant="outline" className="text-xs">{stats.edited} edited</Badge>}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
</div>
|
||||||
disabled={isSearching}
|
)}
|
||||||
/>
|
|
||||||
<Button onClick={handleSearch} disabled={isSearching || !searchQuery.trim()}>
|
|
||||||
{isSearching ? "Searching..." : "Search"}
|
|
||||||
</Button>
|
|
||||||
{showSearch && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setShowSearch(false);
|
|
||||||
setSearchResults([]);
|
|
||||||
setSearchQuery("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showSearch && searchResults.length > 0 && (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Found {searchResults.length} result{searchResults.length !== 1 ? "s" : ""}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Tabs defaultValue="all">
|
{/* Search + Filter row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-9"
|
||||||
|
placeholder="Search message content..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
disabled={isSearching}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSearch} disabled={isSearching || !searchQuery.trim()} size="sm">
|
||||||
|
{isSearching ? "Searching..." : "Search"}
|
||||||
|
</Button>
|
||||||
|
{showSearch && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => { setShowSearch(false); setSearchResults([]); setSearchQuery(""); }}>
|
||||||
|
<X className="mr-1 h-3 w-3" /> Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{(["all", "clean", "warn", "flagged", "error", "pending"] as AiFilter[]).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setAiFilter(f)}
|
||||||
|
className={`rounded-md px-2 py-1 text-xs font-medium transition-colors ${aiFilter === f ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSearch && searchResults.length > 0 && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Found {searchResults.length} result{searchResults.length !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* View tabs */}
|
||||||
|
<Tabs value={viewTab} onValueChange={(v) => setViewTab(v as "all" | "images")}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="all">
|
<TabsTrigger value="all">
|
||||||
{showSearch ? "Search Results" : "All Messages"}
|
{showSearch ? `Search (${filteredMessages.length})` : `All (${filteredMessages.length})`}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="images">Images</TabsTrigger>
|
<TabsTrigger value="images">Images</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="all">
|
<TabsContent value="all">
|
||||||
<MessageFeed
|
<MessageFeed
|
||||||
messages={displayMessages}
|
messages={filteredMessages}
|
||||||
onReanalyze={onReanalyze}
|
onReanalyze={onReanalyze}
|
||||||
emptyText={
|
emptyText={
|
||||||
showSearch
|
showSearch
|
||||||
@@ -146,7 +195,7 @@ export function MessagesPanel({
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="images">
|
<TabsContent value="images">
|
||||||
<ImageGrid messages={displayMessages} />
|
<ImageGrid messages={filteredMessages} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user