style(frontend): refactor UI components with sky color palette, improved spacing, and design polish

This commit is contained in:
MythEclipse
2026-06-03 03:18:04 +07:00
parent 20681ef308
commit e8deba93cc
14 changed files with 240 additions and 161 deletions
@@ -38,13 +38,13 @@ export function Heatmap({ cells, loading }: HeatmapProps) {
}
function getHeatClass(intensity: number): string {
if (intensity === 0) return "bg-muted/30";
if (intensity < 0.1) return "bg-blue-500/10";
if (intensity < 0.2) return "bg-blue-500/20";
if (intensity < 0.35) return "bg-blue-500/30";
if (intensity < 0.5) return "bg-blue-500/45";
if (intensity < 0.7) return "bg-blue-500/60";
return "bg-blue-500/80";
if (intensity === 0) return "bg-sky-50";
if (intensity < 0.1) return "bg-sky-200/30";
if (intensity < 0.2) return "bg-sky-300/30";
if (intensity < 0.35) return "bg-sky-400/35";
if (intensity < 0.5) return "bg-sky-400/55";
if (intensity < 0.7) return "bg-primary/60";
return "bg-primary/80";
}
return (
@@ -54,14 +54,14 @@ export function Heatmap({ cells, loading }: HeatmapProps) {
Heatmap Aktivitas
</CardTitle>
<CardDescription className="text-xs">
Hari × jam area biru = lebih ramai.
Hari × jam area biru langit = lebih ramai.
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="min-w-[520px]">
{/* Header row */}
<div className="mb-1 ml-8 flex gap-[2px]">
<div className="mb-1 ml-8 flex gap-[3px]">
{Array.from({ length: 24 }, (_, h) => (
<div
key={h}
@@ -73,7 +73,7 @@ export function Heatmap({ cells, loading }: HeatmapProps) {
</div>
{/* Rows */}
{DAYS.map((day, d) => (
<div key={d} className="mb-[2px] flex items-center gap-[2px]">
<div key={d} className="mb-[3px] flex items-center gap-[3px]">
<div className="w-8 shrink-0 text-right pr-1 text-[10px] text-muted-foreground">
{day}
</div>
@@ -84,7 +84,7 @@ export function Heatmap({ cells, loading }: HeatmapProps) {
<div
key={h}
className={cn(
"flex-1 rounded-sm aspect-square",
"flex-1 rounded-md aspect-square border border-sky-100/50",
getHeatClass(intensity),
)}
title={`${day} ${h}:00 — ${cell?.count ?? 0} pesan`}
@@ -98,10 +98,10 @@ export function Heatmap({ cells, loading }: HeatmapProps) {
{/* Legend */}
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>Sepi</span>
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-muted/30" />
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/20" />
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/45" />
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/80" />
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-sky-50" />
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-sky-300/30" />
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-sky-400/55" />
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-primary/80" />
<span>Ramai</span>
</div>
</CardContent>
@@ -37,7 +37,7 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Tren Harian</CardTitle>
<CardDescription className="text-xs">
Volume pesan per hari dengan status moderasi.
Volume pesan per hari dengan status moderasi area pink = flagged.
</CardDescription>
</CardHeader>
<CardContent>
@@ -48,7 +48,7 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
<LegendDot color="bg-accent" label="Flagged" />
</div>
<div className="overflow-hidden rounded-2xl border border-border bg-background/50 p-4">
<div className="overflow-hidden rounded-2xl border border-sky-100 bg-white/60 p-4">
<div className="mb-3 flex items-center justify-between text-[11px] text-muted-foreground">
<span>Rangkuman 7 hari terakhir</span>
<span>{totalMessages} total pesan</span>
@@ -61,16 +61,24 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
>
<defs>
<linearGradient id="trendFill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.35" />
<stop offset="0%" stopColor="#7EC8E3" stopOpacity="0.35" />
<stop
offset="100%"
stopColor="#3b82f6"
stopColor="#7EC8E3"
stopOpacity="0.02"
/>
</linearGradient>
<linearGradient id="trendFillPink" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#FF9EBB" stopOpacity="0.3" />
<stop
offset="100%"
stopColor="#FF9EBB"
stopOpacity="0.02"
/>
</linearGradient>
</defs>
<g stroke="#334155" strokeWidth="1" opacity="0.35">
<g stroke="#cbd5e1" strokeWidth="1" opacity="0.35">
{Array.from({ length: 4 }, (_, index) => {
const y = 40 + index * 45;
return (
@@ -89,32 +97,32 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
data={data}
keyName="total"
fill="url(#trendFill)"
stroke="#3b82f6"
stroke="#7EC8E3"
/>
<TrendLine
data={data}
keyName="total"
color="#3b82f6"
color="#7EC8E3"
strokeWidth={2.5}
/>
<TrendLine
data={data}
keyName="clean"
color="#10b981"
strokeWidth={1.8}
/>
<TrendLine
data={data}
keyName="warned"
color="#f59e0b"
color="#34d399"
strokeWidth={1.8}
/>
<TrendLine
data={data}
keyName="flagged"
color="#ef4444"
color="#FF9EBB"
strokeWidth={1.8}
/>
<TrendArea
data={data}
keyName="flagged"
fill="url(#trendFillPink)"
stroke="#FF9EBB"
/>
{data.map((item, index) => {
const x =
@@ -124,7 +132,7 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
Math.max((data.length - 1) * 56, 56);
return (
<g key={item.date} transform={`translate(${x}, 188)`}>
<circle cx="0" cy="0" r="2.5" fill="#e2e8f0" />
<circle cx="0" cy="0" r="2.5" fill="#7EC8E3" />
<text
x="0"
y="18"
@@ -1,4 +1,5 @@
import { Users } from "lucide-react";
import { cn } from "../../../shared/lib/utils";
import type { UserStat } from "../../../shared/api/client";
import {
Badge,
@@ -37,7 +38,7 @@ export function UserTable({ users, loading }: UserTableProps) {
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Users className="h-4 w-4 text-violet-400" />
<Users className="h-4 w-4 text-primary" />
User Paling Aktif
</CardTitle>
<CardDescription className="text-xs">
@@ -48,7 +49,7 @@ export function UserTable({ users, loading }: UserTableProps) {
<ScrollArea className="max-h-[260px]">
<table className="w-full text-sm">
<thead>
<tr className="sticky top-0 z-10 bg-card/95 backdrop-blur border-b border-border text-left text-[10px] uppercase tracking-wider text-muted-foreground">
<tr className="sticky top-0 z-10 bg-white border-b border-sky-100 text-left text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
<th className="py-2 pr-2 font-semibold">User</th>
<th className="py-2 pr-2 font-semibold text-right">Pesan</th>
@@ -56,11 +57,14 @@ export function UserTable({ users, loading }: UserTableProps) {
<th className="py-2 pr-4 font-semibold text-right">Flag</th>
</tr>
</thead>
<tbody className="divide-y divide-border/20">
<tbody className="divide-y divide-sky-50">
{users.map((user, i) => (
<tr
key={user.user_id}
className="hover:bg-muted/20 transition-colors"
className={cn(
"transition-colors rounded-xl",
i % 2 === 0 ? "bg-white" : "bg-sky-50/20",
)}
>
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
{medals[i] ?? i + 1}
@@ -71,11 +75,11 @@ export function UserTable({ users, loading }: UserTableProps) {
<img
src={user.avatar_url}
alt=""
className="h-6 w-6 rounded-full"
className="h-6 w-6 rounded-full ring-2 ring-sky-100"
loading="lazy"
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-[10px] font-bold">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-sky-100 text-[10px] font-bold text-primary">
{user.username.charAt(0).toUpperCase()}
</div>
)}
@@ -86,9 +90,9 @@ export function UserTable({ users, loading }: UserTableProps) {
</td>
<td className="py-1.5 pr-2 text-right">
<div className="flex items-center justify-end gap-1.5">
<div className="h-1 w-8 overflow-hidden rounded-full bg-muted">
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-sky-50">
<div
className="h-full rounded-full bg-blue-500/60"
className="h-full rounded-full bg-gradient-to-r from-primary to-pink-300"
style={{
width: `${(user.message_count / maxMsgs) * 100}%`,
}}
@@ -1,4 +1,5 @@
import { Siren } from "lucide-react";
import { cn } from "../../../shared/lib/utils";
import type { ViolatorStat } from "../../../shared/api/client";
import {
Badge,
@@ -1,6 +1,6 @@
import type { ActiveSpeaker } from "../../../shared/api/client";
import { Skeleton } from "../../../shared/ui";
import { EmptyStateMascot } from "../../../widgets/mascot/ChibiMascot";
import { EmptyStateMascot } from "../../../shared/ui";
interface ActiveSpeakersProps {
speakers: ActiveSpeaker[];
@@ -43,7 +43,7 @@ export function MusicSubPanel({
};
return (
<div className="space-y-4">
<div className="rounded-2xl border border-sky-200 bg-white p-4 shadow-md space-y-4">
<Input
value={source}
onChange={(e) => setSource(e.target.value)}
@@ -51,7 +51,7 @@ export function MusicSubPanel({
placeholder="YouTube URL, Spotify track, or search terms"
/>
<div className="flex items-center gap-3">
<Volume2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<Volume2 className="h-4 w-4 shrink-0 text-[#7EC8E3]" />
<input
type="range"
min={0}
@@ -59,14 +59,14 @@ export function MusicSubPanel({
step={1}
value={draftVolume}
onChange={(e) => setDraftVolume(Number(e.target.value))}
className="h-2 w-full cursor-pointer accent-primary"
className="h-2 w-full cursor-pointer accent-[#7EC8E3]"
/>
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">
{draftVolume}%
</span>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={loading || !source.trim()} onClick={submit}>
<Button className="bg-[#7EC8E3] text-white hover:bg-[#7EC8E3]/80" disabled={loading || !source.trim()} onClick={submit}>
<Music2 className="mr-1.5 h-4 w-4" /> Queue
</Button>
<Button variant="secondary" disabled={loading} onClick={onSkip}>
@@ -11,10 +11,10 @@ export function NowPlaying({ current, queue }: NowPlayingProps) {
if (!current) return null;
return (
<div className="rounded-2xl border border-border bg-card shadow-sm">
<div className="rounded-2xl border border-sky-200 bg-white shadow-md">
<div className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#7EC8E3]/15 text-[#7EC8E3]">
{current.mode === "screen" ? (
<MonitorUp className="h-5 w-5" />
) : (
@@ -34,15 +34,15 @@ export function NowPlaying({ current, queue }: NowPlayingProps) {
</div>
{queue.length > 0 && (
<div className="border-t border-border p-4">
<div className="border-t border-sky-100 p-4">
<div className="mb-2 text-sm font-medium">Queue ({queue.length})</div>
<div className="space-y-1.5">
{queue.map((item, i) => (
<div
key={`${item.source}-${i}`}
className="flex items-center gap-3 rounded-lg border border-border bg-background/60 p-2.5 text-sm"
className="flex items-center gap-3 rounded-lg border-l-2 border-l-[#7EC8E3] border border-sky-200 bg-white p-2.5 text-sm"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[#7EC8E3]/15 text-xs font-medium text-[#7EC8E3]">
{i + 1}
</span>
<div className="min-w-0">
@@ -2,7 +2,7 @@
import { Download, Mic } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge, Button, Skeleton } from "../../../shared/ui";
import { Badge, Button, EmptyStateMascot, Skeleton } from "../../../shared/ui";
import { formatBytes, formatDate } from "../../../shared/lib/utils";
interface VoiceRecording {
@@ -61,7 +61,7 @@ export function RecordingsSubPanel() {
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex items-center gap-4 rounded-xl border border-border bg-background/60 p-4"
className="flex items-center gap-4 rounded-xl border border-sky-200 bg-white p-4"
>
<Skeleton className="h-10 w-10 rounded-xl" />
<div className="flex-1 space-y-2">
@@ -93,9 +93,7 @@ export function RecordingsSubPanel() {
if (recordings.length === 0) {
return (
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
No recordings found.
</div>
<EmptyStateMascot variant="sleeping" message="No recordings yet~" />
);
}
@@ -104,9 +102,9 @@ export function RecordingsSubPanel() {
{recordings.map((rec) => (
<div
key={rec.id}
className="flex items-center gap-4 rounded-xl border border-border bg-background/60 p-4"
className="flex items-center gap-4 rounded-xl border border-sky-200 bg-white p-4"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#7EC8E3]/15 text-[#7EC8E3]">
<Mic className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
@@ -143,7 +141,7 @@ export function RecordingsSubPanel() {
href={rec.download_url}
target="_blank"
rel="noreferrer"
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
className="rounded-lg bg-[#7EC8E3] px-3 py-1.5 text-sm font-medium text-white hover:bg-[#7EC8E3]/80"
>
<Download className="h-4 w-4" />
</a>
@@ -24,7 +24,7 @@ export function ScreenSubPanel({
};
return (
<div className="space-y-4">
<div className="rounded-2xl border border-sky-200 bg-white p-4 shadow-md space-y-4">
<Input
value={source}
onChange={(e) => setSource(e.target.value)}
@@ -32,7 +32,7 @@ export function ScreenSubPanel({
placeholder="Screen share URL or local file path"
/>
<div className="flex flex-wrap gap-2">
<Button disabled={loading || !source.trim()} onClick={submit}>
<Button className="bg-[#7EC8E3] text-white hover:bg-[#7EC8E3]/80" disabled={loading || !source.trim()} onClick={submit}>
<MonitorUp className="mr-1.5 h-4 w-4" /> Start
</Button>
<Button variant="secondary" disabled={loading} onClick={onSkip}>
@@ -1,5 +1,6 @@
import type { MessageRecord } from "../../../shared/api/client";
import { parseMetadata } from "../../../entities/message/types";
import { EmptyStateMascot } from "../../../widgets/mascot/ChibiMascot";
interface ImageItem {
url: string;
@@ -8,6 +9,17 @@ interface ImageItem {
message: MessageRecord;
}
function kindBadge(kind: ImageItem["kind"]): string {
switch (kind) {
case "sticker":
return "bg-pink-100 text-pink-700 border-pink-200";
case "attachment":
return "bg-primary-soft text-primary border-primary/30";
case "embed":
return "bg-purple-100 text-purple-700 border-purple-200";
}
}
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
const images: ImageItem[] = [];
@@ -57,9 +69,7 @@ export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
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>
<EmptyStateMascot variant="thinking" message="No images yet~" />
);
}
@@ -74,7 +84,7 @@ export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
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"
className="group overflow-hidden rounded-2xl border border-primary/20 bg-white shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
>
<div className="relative aspect-video overflow-hidden">
{image.kind === "sticker" ? (
@@ -92,14 +102,16 @@ export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
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">
<span
className={`absolute right-2 top-2 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider shadow-sm backdrop-blur ${kindBadge(image.kind)}`}
>
{image.kind}
</div>
</span>
</div>
<div className="p-3">
<div className="truncate text-sm font-medium">{image.title}</div>
<div className="flex items-center gap-2">
<div className="h-4 w-4 overflow-hidden rounded-full">
<div className="h-4 w-4 overflow-hidden rounded-full ring-1 ring-primary/30">
<img
src={
image.message.avatar_url ??
@@ -94,13 +94,13 @@ function aiVariant(status: string) {
function severityColor(severity: string) {
switch (severity) {
case "critical":
return "bg-red-500/20 text-red-300 border-red-500/30";
return "bg-red-100 text-red-700 border-red-200";
case "high":
return "bg-orange-500/20 text-orange-300 border-orange-500/30";
return "bg-orange-100 text-orange-700 border-orange-200";
case "medium":
return "bg-yellow-500/20 text-yellow-300 border-yellow-500/30";
return "bg-yellow-100 text-yellow-700 border-yellow-200";
case "low":
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
return "bg-blue-100 text-blue-700 border-blue-200";
default:
return "bg-muted text-muted-foreground border-border";
}
@@ -175,7 +175,13 @@ export function MessageCard({
return (
<article
className={`group rounded-2xl border border-border bg-card ${compact ? "px-4 py-1.5" : "p-4"} shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${message.deleted_at ? "opacity-60" : ""}`}
className={`group rounded-2xl border bg-white shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
compact ? "px-4 py-1.5" : "p-4"
} ${
message.deleted_at
? "border-red-200 opacity-60"
: "border-primary/20"
}`}
>
<div className={`flex ${compact ? "gap-2" : "gap-3"}`}>
{!compact && (
@@ -185,7 +191,7 @@ export function MessageCard({
"https://cdn.discordapp.com/embed/avatars/0.png"
}
alt=""
className="h-10 w-10 shrink-0 rounded-full object-cover ring-1 ring-border"
className="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30"
/>
)}
<div
@@ -319,10 +325,10 @@ export function MessageCard({
{message.ai_analysis ? (
<div
className={`rounded-xl border-l-2 p-3 ${
className={`rounded-xl border-l-4 p-3 ${
aiStatus === "flagged"
? "border-l-red-500 bg-red-500/5"
: "border-l-blue-500 bg-blue-500/5"
? "border-l-pink-400 bg-pink-50/50"
: "border-l-emerald-400 bg-emerald-50/50"
}`}
>
<button
@@ -331,9 +337,7 @@ export function MessageCard({
className="flex w-full items-center justify-between gap-2 text-left text-xs"
>
<span className="font-medium text-foreground/80">
{aiStatus === "flagged"
? "🚨"
: "️"}{" "}
{aiStatus === "flagged" ? "🚨 " : "️ "}
{analysisSummary}
</span>
{showAnalysis ? (
@@ -351,7 +355,7 @@ export function MessageCard({
) : null}
{message.ai_error ? (
<div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-xl bg-pink-50/50 p-3 text-sm text-pink-700">
AI error: {message.ai_error}
</div>
) : null}
@@ -370,7 +374,7 @@ export function MessageCard({
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
</Button>
{aiStatus === "error" && (
<span className="text-xs text-destructive/80">
<span className="text-xs text-pink-600/80">
Click to retry analysis
</span>
)}
@@ -383,7 +387,7 @@ export function MessageCard({
export function MessageCardSkeleton() {
return (
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
<article className="rounded-2xl border border-primary/20 bg-white p-4 shadow-sm">
<div className="flex gap-3">
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-3">
@@ -1,7 +1,13 @@
import { motion } from "framer-motion";
import { useEffect, useMemo, useRef } from "react";
import type { MessageRecord } from "../../../shared/api/client";
import {
cardStagger,
cardItem,
} from "../../../shared/hooks/useFramerStagger";
import { ScrollArea } from "../../../shared/ui";
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
import { EmptyStateMascot } from "../../../widgets/mascot/ChibiMascot";
export interface MessageFeedProps {
messages: MessageRecord[];
@@ -81,27 +87,29 @@ export function MessageFeed({
}
if (messages.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
{emptyText}
</div>
);
return <EmptyStateMascot variant="waving" message="No messages yet~" />;
}
return (
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
<div className="space-y-3">
<motion.div
className="space-y-3"
variants={cardStagger}
initial="initial"
animate="animate"
>
{groupedMessages.map((group) =>
group.messages.map((message, idx) => {
const isFirstInGroup = idx === 0;
const isCompact = !isFirstInGroup;
return (
<MessageCard
key={message.id}
message={message}
onReanalyze={onReanalyze}
compact={isCompact}
/>
<motion.div key={message.id} variants={cardItem}>
<MessageCard
message={message}
onReanalyze={onReanalyze}
compact={isCompact}
/>
</motion.div>
);
}),
)}
@@ -115,11 +123,11 @@ export function MessageFeed({
{loadingMore ? (
<MessageCardSkeleton />
) : (
<div className="h-2 w-2 rounded-full bg-muted-foreground/40" />
<div className="h-2 w-2 rounded-full bg-primary/40" />
)}
</div>
)}
</div>
</motion.div>
</ScrollArea>
);
}
@@ -13,6 +13,22 @@ interface AlertDetail {
brief: string;
}
function severityToToastType(
severity: string,
): "error" | "warning" | "info" | "success" {
switch (severity) {
case "critical":
case "high":
return "error";
case "medium":
return "warning";
case "low":
return "info";
default:
return "warning";
}
}
export function ModerationAlertListener() {
const { addToast } = useToast();
@@ -29,7 +45,7 @@ export function ModerationAlertListener() {
addToast(
`🚨 ${username} ${sevLabel}${catLabel}: ${brief}`,
"error",
severityToToastType(severity),
);
};
@@ -1,5 +1,6 @@
import { Filter, RotateCw, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import { motion } from "framer-motion";
import type { MessageRecord } from "../../shared/api/client";
import {
Badge,
@@ -14,6 +15,7 @@ import {
TabsList,
TabsTrigger,
} from "../../shared/ui";
import { cardStagger, cardItem } from "../../shared/hooks/useFramerStagger";
import { ImageGrid } from "./components/ImageGrid";
import { MessageFeed } from "./components/MessageFeed";
@@ -94,53 +96,65 @@ export function MessagesPanel({
}, [messages, searchResults, showSearch, aiFilter]);
return (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Messages</CardTitle>
{guildName && (
<p className="text-sm text-muted-foreground">
Monitoring all text channels in{" "}
<span className="font-medium text-foreground">{guildName}</span>
<motion.div
className="grid gap-6"
variants={cardStagger}
initial="initial"
animate="animate"
>
<motion.div variants={cardItem}>
<Card className="border-primary/20">
<CardHeader>
<CardTitle className="text-primary">Messages</CardTitle>
{guildName && (
<p className="text-sm text-muted-foreground">
Monitoring all text channels in{" "}
<span className="font-medium text-foreground">
{guildName}
</span>
</p>
)}
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
Messages are automatically captured from all text channels in the
monitored guild. Real-time updates arrive via WebSocket.
</p>
)}
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
Messages are automatically captured from all text channels in the
monitored guild. Real-time updates arrive via WebSocket.
</p>
</CardContent>
</Card>
</CardContent>
</Card>
</motion.div>
{stats.total > 0 && (
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-xs">
<motion.div
variants={cardItem}
className="flex flex-wrap items-center gap-2"
>
<Badge variant="outline" className="text-xs border-primary/40 text-primary">
{stats.total} total{hasMore && !showSearch ? "+" : ""}
</Badge>
<Badge
variant="outline"
className="text-xs text-green-400 border-green-400/30"
className="text-xs bg-emerald-100 text-emerald-700 border-emerald-200"
>
{stats.clean} clean
</Badge>
<Badge
variant="outline"
className="text-xs text-red-400 border-red-400/30"
className="text-xs bg-pink-100 text-pink-700 border-pink-200"
>
{stats.flagged} flagged
</Badge>
<Badge
variant="outline"
className="text-xs text-orange-400 border-orange-400/30"
className="text-xs bg-orange-100 text-orange-700 border-orange-200"
>
{stats.error} error
</Badge>
<Badge variant="outline" className="text-xs">
<Badge variant="outline" className="text-xs text-muted-foreground border-border">
{stats.pending} pending
</Badge>
{stats.deleted > 0 && (
<Badge variant="destructive" className="text-xs">
<Badge variant="outline" className="text-xs bg-red-100 text-red-700 border-red-200">
{stats.deleted} deleted
</Badge>
)}
@@ -149,14 +163,17 @@ export function MessagesPanel({
{stats.edited} edited
</Badge>
)}
</div>
</motion.div>
)}
<div className="flex flex-wrap items-center gap-2">
<motion.div
variants={cardItem}
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" />
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-primary" />
<Input
className="pl-9"
className="pl-9 rounded-full focus-visible:ring-primary"
placeholder="Search message content..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
@@ -168,6 +185,7 @@ export function MessagesPanel({
onClick={handleSearch}
disabled={isSearching || !searchQuery.trim()}
size="sm"
className="rounded-xl"
>
{isSearching ? "Searching..." : "Search"}
</Button>
@@ -199,6 +217,7 @@ export function MessagesPanel({
setRetryingAll(false);
}
}}
className="rounded-xl bg-pink-100 text-pink-700 hover:bg-pink-200 border-pink-200"
>
<RotateCw
className={`mr-1.5 h-3.5 w-3.5 ${retryingAll ? "animate-spin" : ""}`}
@@ -209,13 +228,13 @@ export function MessagesPanel({
</Button>
)}
{retriedCount !== null && (
<span className="text-xs text-green-400">
<span className="text-xs text-emerald-600">
{retriedCount} message{retriedCount !== 1 ? "s" : ""} queued for
re-analysis
</span>
)}
<div className="ml-auto flex items-center gap-1.5">
<Filter className="h-4 w-4 text-muted-foreground" />
<Filter className="h-4 w-4 text-primary" />
{(
[
"all",
@@ -228,51 +247,60 @@ export function MessagesPanel({
<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"}`}
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
aiFilter === f
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-primary-soft"
}`}
>
{f}
</button>
))}
</div>
</div>
</motion.div>
{showSearch && searchResults.length > 0 && (
<div className="text-sm text-muted-foreground">
<motion.div
variants={cardItem}
className="text-sm text-muted-foreground"
>
Found {searchResults.length} result
{searchResults.length !== 1 ? "s" : ""}
</div>
</motion.div>
)}
<Tabs
value={viewTab}
onValueChange={(v) => setViewTab(v as "all" | "images")}
>
<TabsList>
<TabsTrigger value="all">
{showSearch
? `Search (${filteredMessages.length})`
: `All (${filteredMessages.length})`}
</TabsTrigger>
<TabsTrigger value="images">Images</TabsTrigger>
</TabsList>
<TabsContent value="all">
<MessageFeed
messages={filteredMessages}
onReanalyze={onReanalyze}
emptyText={
showSearch
? "No messages found matching your search."
: "No captures yet."
}
onLoadMore={showSearch ? undefined : onLoadMore}
hasMore={showSearch ? false : hasMore}
loadingMore={loadingMore}
/>
</TabsContent>
<TabsContent value="images">
<ImageGrid messages={filteredMessages} />
</TabsContent>
</Tabs>
</div>
<motion.div variants={cardItem}>
<Tabs
value={viewTab}
onValueChange={(v) => setViewTab(v as "all" | "images")}
>
<TabsList>
<TabsTrigger value="all">
{showSearch
? `Search (${filteredMessages.length})`
: `All (${filteredMessages.length})`}
</TabsTrigger>
<TabsTrigger value="images">Images</TabsTrigger>
</TabsList>
<TabsContent value="all">
<MessageFeed
messages={filteredMessages}
onReanalyze={onReanalyze}
emptyText={
showSearch
? "No messages found matching your search."
: "No captures yet."
}
onLoadMore={showSearch ? undefined : onLoadMore}
hasMore={showSearch ? false : hasMore}
loadingMore={loadingMore}
/>
</TabsContent>
<TabsContent value="images">
<ImageGrid messages={filteredMessages} />
</TabsContent>
</Tabs>
</motion.div>
</motion.div>
);
}