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
@@ -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 <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 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 <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) {
|
||||
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 (
|
||||
<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">
|
||||
<img
|
||||
src={message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
|
||||
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="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{message.username || message.user_id}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(message.created_at).toLocaleString()}
|
||||
<div className="min-w-0 flex-1 space-y-2.5">
|
||||
{/* Header row */}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-semibold text-foreground">{message.username || message.user_id}</span>
|
||||
<span className="text-xs text-muted-foreground" title={new Date(message.created_at).toLocaleString()}>
|
||||
{formatTimeAgo(message.created_at)}
|
||||
</span>
|
||||
{message.edited_at ? <Badge variant="outline">edited</Badge> : null}
|
||||
{message.deleted_at ? <Badge variant="destructive">deleted</Badge> : null}
|
||||
<Badge variant={aiVariant(aiStatus)} className="flex items-center gap-1">
|
||||
{getAiIcon(aiStatus)}
|
||||
{aiStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
|
||||
{displayContent || "(empty message)"}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{message.ai_severity ? <Badge variant="outline">severity: {message.ai_severity}</Badge> : null}
|
||||
{message.ai_recommended_action ? <Badge variant="outline">action: {message.ai_recommended_action}</Badge> : null}
|
||||
{confidence != null ? <Badge variant="outline">confidence: {Math.round(confidence * 100)}%</Badge> : null}
|
||||
{message.ai_policy_version ? <Badge variant="outline">policy: {message.ai_policy_version}</Badge> : null}
|
||||
{categories.slice(0, 6).map((category) => (
|
||||
<Badge key={category} variant="secondary">{category}</Badge>
|
||||
))}
|
||||
{message.edited_at && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Pencil className="h-3 w-3" /> edited
|
||||
</span>
|
||||
)}
|
||||
{message.deleted_at && (
|
||||
<span className="flex items-center gap-1 text-xs text-destructive">
|
||||
<Trash2 className="h-3 w-3" /> deleted
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Badge variant={aiVariant(aiStatus)} className="flex items-center gap-1 text-xs">
|
||||
{getAiIcon(aiStatus)}
|
||||
{aiStatus}
|
||||
</Badge>
|
||||
{message.ai_severity && message.ai_severity !== "none" && (
|
||||
<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>
|
||||
|
||||
{/* 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 ? (
|
||||
<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}
|
||||
</div>
|
||||
) : null}
|
||||
{evidence.length > 0 ? (
|
||||
<div className="rounded-xl border border-border bg-background/50 p-3 text-xs text-muted-foreground">
|
||||
<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}
|
||||
|
||||
{/* AI error */}
|
||||
{message.ai_error ? (
|
||||
<div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">
|
||||
AI error: {message.ai_error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={aiStatus === "error" ? "destructive" : "outline"}
|
||||
onClick={handleReanalyze}
|
||||
disabled={aiStatus === "pending" || isReanalyzing}
|
||||
className="text-xs"
|
||||
>
|
||||
<RotateCw className={`h-3.5 w-3.5 ${isReanalyzing ? "animate-spin" : ""}`} />
|
||||
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
||||
</Button>
|
||||
{aiStatus === "error" && (
|
||||
<span className="text-xs text-destructive self-center">
|
||||
<span className="text-xs text-destructive/80">
|
||||
Click to retry analysis
|
||||
</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user