refactor: comprehensive codebase cleanup and architecture hardening

- Sprint 1 (Quick Wins): Remove dead analytics modules, fix 4 unresolved
  imports, replace 3 console.warn with logger, remove mock-crc import
- Sprint 2 (Architecture): Create MascotChatRepository, AnalysisRepository,
  3 Zod schemas (mascot-chat, analysis, voice), deduplicate error classes,
  move 3 SQL queries from routes to repository
- Sprint 3 (Complexity): Replace 7 any types with proper interfaces,
  extract 6 helpers from prepareMediaMessage (CC 85 -> ~15)
- Sprint 4 (Config): Remove 22 dead env vars from .env, add 30 missing
  vars to .env.example, standardize naming

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-09 10:16:04 +07:00
co-authored by Claude Opus 4.8
parent d0d9e1669e
commit 4becf0d6f1
89 changed files with 1260 additions and 5499 deletions
+8 -53
View File
@@ -1,4 +1,4 @@
import { Component, lazy, Suspense, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { AuthOverlay } from "./features/auth";
import { LivePanel } from "./features/live";
import { useMediaControl } from "./features/live/hooks/useMediaControl";
@@ -18,38 +18,10 @@ import {
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
import { useUIState } from "./shared/hooks/useUIState";
import { Skeleton } from "./shared/ui";
import { MobileTabBar } from "./shared/ui/MobileTabBar";
import { useDashboardSocket } from "./shared/ws/socket";
import { DashboardLayout } from "./widgets/DashboardLayout";
const AnalyticsPanel = lazy(() =>
import("./features/analytics").then((module) => ({
default: module.AnalyticsPanel,
})),
);
class AnalyticsErrorBoundary extends Component<
{ children: React.ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
override render() {
if (this.state.hasError) {
return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 p-6 text-sm text-destructive">
Analytics failed to load. The rest of the dashboard is still
available.
</div>
);
}
return this.props.children;
}
}
export default function App() {
const { uiState, patchUIState } = useUIState();
const voice = useVoiceControl();
@@ -76,7 +48,8 @@ export default function App() {
);
const socket = useDashboardSocket({
onVoicePcmData: (d) => audio.handleIncomingPcm(d as { userId: string; pcm: string }),
onVoicePcmData: (d) =>
audio.handleIncomingPcm(d as { userId: string; pcm: string }),
onUserState: (users) => setActiveSpeakers(users as ActiveSpeaker[]),
onMessageCreated: (m) =>
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
@@ -144,9 +117,7 @@ export default function App() {
// Auto-fetch messages for the monitor guild
useEffect(() => {
if (monitorGuildId)
messages
.fetchMessages(monitorGuildId)
.catch(() => undefined);
messages.fetchMessages(monitorGuildId).catch(() => undefined);
}, [monitorGuildId, messages.fetchMessages]);
// Periodic refetch — keeps dashboard in sync even if WS events missed
@@ -166,7 +137,9 @@ export default function App() {
onTabChange={(tab) => patchUIState({ activeTab: tab })}
recentMessages={messages.messages}
guildId={monitorGuildId}
channelId={uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined}
channelId={
uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined
}
>
{activeTab === "live" ? (
!isAuthenticated ? (
@@ -205,7 +178,7 @@ export default function App() {
onVolumeChange={media.setVolume}
/>
)
) : activeTab === "messages" ? (
) : (
<MessagesPanel
guildName={monitorGuildName}
messages={messages.messages}
@@ -215,24 +188,6 @@ export default function App() {
hasMore={messages.hasMore}
loadingMore={messages.loadingMore}
/>
) : (
<AnalyticsErrorBoundary>
<Suspense
fallback={
<div className="flex flex-col gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-xl" />
))}
<Skeleton className="h-64 w-full rounded-xl" />
</div>
}
>
<AnalyticsPanel
guildId={monitorGuildId}
guildName={monitorGuildName}
/>
</Suspense>
</AnalyticsErrorBoundary>
)}
<MobileTabBar
activeTab={activeTab}
@@ -1,340 +0,0 @@
import { useState } from "react";
import type { AIStats } from "../../../shared/api/client";
import { cn } from "../../../shared/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../shared/ui";
interface AIDistributionPanelProps {
stats: AIStats | null;
loading: boolean;
}
const SEVERITY_META: Record<
string,
{ label: string; color: string; darkColor: string }
> = {
critical: {
label: "Critical",
color: "#e11d48",
darkColor: "#be123c",
},
high: {
label: "High",
color: "#f43f5e",
darkColor: "#e11d48",
},
medium: {
label: "Medium",
color: "#fb923c",
darkColor: "#f97316",
},
low: {
label: "Low",
color: "#facc15",
darkColor: "#eab308",
},
none: {
label: "None",
color: "#94a3b8",
darkColor: "#64748b",
},
};
const ACTION_META: Record<
string,
{ label: string; color: string }
> = {
escalate: { label: "Escalate", color: "#e11d48" },
delete: { label: "Delete", color: "#f43f5e" },
review: { label: "Review", color: "#fb923c" },
warn: { label: "Warn", color: "#facc15" },
monitor: { label: "Monitor", color: "#38bdf8" },
none: { label: "None", color: "#94a3b8" },
};
function DonutChart({
entries,
size = 140,
strokeWidth = 22,
}: {
entries: Array<{ key: string; value: number; color: string; label: string }>;
size?: number;
strokeWidth?: number;
}) {
const total = entries.reduce((sum, e) => sum + e.value, 0);
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
if (total === 0) {
return (
<div className="flex items-center justify-center text-[11px] text-muted-foreground">
No data
</div>
);
}
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
let cumulative = 0;
const segments = entries
.filter((e) => e.value > 0)
.map((e) => {
const offset = cumulative;
const length = (e.value / total) * circumference;
cumulative += length;
return { ...e, length, offset };
});
return (
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
{/* Background ring */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="hsl(var(--muted))"
strokeWidth={strokeWidth}
opacity={0.2}
/>
{/* Segments */}
{segments.map((seg) => {
const isHovered = hoveredKey === seg.key;
return (
<circle
key={seg.key}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={seg.color}
strokeWidth={strokeWidth}
strokeDasharray={`${seg.length} ${circumference - seg.length}`}
strokeDashoffset={-seg.offset}
strokeLinecap="round"
className={cn(
"transition-all duration-200",
hoveredKey && !isHovered ? "opacity-30" : "opacity-100",
)}
onMouseEnter={() => setHoveredKey(seg.key)}
onMouseLeave={() => setHoveredKey(null)}
style={{
filter: isHovered ? `drop-shadow(0 0 4px ${seg.color}80)` : undefined,
}}
/>
);
})}
</svg>
{/* Center label */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="text-lg font-bold tabular-nums leading-none">
{total}
</span>
<span className="text-[9px] text-muted-foreground mt-0.5">Total</span>
</div>
{/* Hover tooltip */}
{hoveredKey && (() => {
const entry = entries.find((e) => e.key === hoveredKey);
if (!entry) return null;
const pct = ((entry.value / total) * 100).toFixed(0);
return (
<div
className="absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md border border-muted bg-white px-2.5 py-1 text-xs shadow-lg z-10"
>
<span className="font-medium">{entry.label}</span>:{" "}
<span className="tabular-nums">{entry.value}</span> ({pct}%)
</div>
);
})()}
</div>
);
}
function HorizontalBarChart({
entries,
maxValue,
}: {
entries: Array<{ key: string; value: number; color: string; label: string }>;
maxValue: number;
}) {
const effectiveMax = Math.max(maxValue, 1);
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
return (
<div className="space-y-1.5">
{entries.map((e) => {
const isHovered = hoveredKey === e.key;
const widthPct = (e.value / effectiveMax) * 100;
return (
<div
key={e.key}
className="flex items-center gap-2"
onMouseEnter={() => setHoveredKey(e.key)}
onMouseLeave={() => setHoveredKey(null)}
>
<span className="w-16 text-[10px] font-medium text-right truncate text-muted-foreground">
{e.label}
</span>
<div className="flex-1 h-3 overflow-hidden rounded-md bg-muted/30">
<div
className={cn(
"h-full rounded-md transition-all duration-300",
isHovered ? "opacity-100" : "opacity-80",
)}
style={{
width: `${widthPct}%`,
backgroundColor: e.color,
}}
/>
</div>
<span className="w-8 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
{e.value}
</span>
</div>
);
})}
</div>
);
}
export function AIDistributionPanel({
stats,
loading,
}: AIDistributionPanelProps) {
if (loading && !stats) return <LoadingBox />;
if (!stats || stats.total_analyzed === 0) {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Belum ada data analisis AI.
</CardContent>
</Card>
);
}
const severityEntries = Object.entries(stats.severity)
.map(([key, value]) => {
const m = SEVERITY_META[key] ?? {
label: key,
color: "#94a3b8",
darkColor: "#64748b",
};
return { key, value, color: m.color, label: m.label };
})
.filter((e) => e.value > 0);
const actionEntries = Object.entries(stats.recommended_actions)
.map(([key, value]) => {
const m = ACTION_META[key] ?? {
label: key,
color: "#94a3b8",
};
return { key, value, color: m.color, label: m.label };
})
.filter((e) => e.value > 0);
const maxAction = Math.max(
...actionEntries.map((e) => e.value),
1,
);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<span className="text-lg">🤖</span>
Distribusi Analisis AI
</CardTitle>
<CardDescription className="text-xs">
Sebaran tingkat keparahan dan rekomendasi dari{" "}
{stats.total_analyzed} pesan yang dianalisis.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Severity Donut */}
<div className="flex flex-col items-center gap-3">
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground self-start">
Severity
</h4>
<DonutChart entries={severityEntries} />
{/* Severity legend */}
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1">
{severityEntries.map((e) => (
<span
key={e.key}
className="flex items-center gap-1 text-[10px] text-muted-foreground"
>
<span
className="inline-block h-2 w-2 rounded-sm"
style={{ backgroundColor: e.color }}
/>
{e.label}:{" "}
<span className="font-medium tabular-nums text-foreground">
{e.value}
</span>
</span>
))}
</div>
</div>
{/* Recommended Actions Bar Chart */}
<div>
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Rekomendasi Tindakan
</h4>
<HorizontalBarChart
entries={actionEntries}
maxValue={maxAction}
/>
</div>
</div>
{/* Footer metrics */}
<div className="mt-4 flex flex-wrap gap-3 border-t border-muted pt-3 text-[10px] text-muted-foreground">
<span>
Rerata confidence:{" "}
<strong>{(stats.avg_confidence * 100).toFixed(0)}%</strong>
</span>
<span>
Rerata score:{" "}
<strong>{(stats.avg_score * 100).toFixed(0)}%</strong>
</span>
<span>
Error:{" "}
<strong className="text-destructive">
{stats.analysis_errors}
</strong>
</span>
<span>
Pending:{" "}
<strong className="text-orange-500">
{stats.analysis_pending}
</strong>
</span>
</div>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -1,254 +0,0 @@
import { useState } from "react";
import type { HourlyBucket } from "../../../shared/api/client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../shared/ui";
import { cn } from "../../../shared/lib/utils";
interface ActivityChartProps {
hourly: HourlyBucket[];
loading: boolean;
}
const COLORS = {
clean: { fill: "#38bdf8", label: "Clean" },
warned: { fill: "#facc15", label: "Warned" },
flagged: { fill: "#f472b6", label: "Flagged" },
error: { fill: "#fb923c", label: "Error" },
} as const;
type BarKey = keyof typeof COLORS;
export function ActivityChart({ hourly, loading }: ActivityChartProps) {
const [tooltip, setTooltip] = useState<{
hour: string;
total: number;
} | null>(null);
const [hoveredBar, setHoveredBar] = useState<string | null>(null);
if (loading && !hourly?.length) return <LoadingBox />;
if (!hourly?.length) return <EmptyBox text="Belum ada data untuk periode ini." />;
const data = hourly.map((b) => {
const utcHour = parseInt(b.hour.slice(11, 13), 10);
const jakartaHour = (utcHour + 7) % 24;
return {
hour: `${String(jakartaHour).padStart(2, "0")}:00`,
clean: b.clean,
warned: b.warned,
flagged: b.flagged,
error: b.error,
total: b.count,
};
});
const maxTotal = Math.max(...data.map((d) => d.total), 1);
// Only show every Nth label to avoid crowding
const labelInterval = data.length > 16 ? 2 : 1;
const bars: Array<{ key: BarKey; color: string; label: string }> = [
{ key: "clean", color: COLORS.clean.fill, label: COLORS.clean.label },
{ key: "warned", color: COLORS.warned.fill, label: COLORS.warned.label },
{ key: "flagged", color: COLORS.flagged.fill, label: COLORS.flagged.label },
{ key: "error", color: COLORS.error.fill, label: COLORS.error.label },
];
const CHART_HEIGHT = 200;
const BAR_GROUP_WIDTH = 28;
const BAR_WIDTH = 5;
const GAP = 2;
return (
<Card className="col-span-1 lg:col-span-2">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-sm font-semibold">
Aktivitas per Jam
</CardTitle>
<CardDescription className="text-xs">
Distribusi pesan per jam arahkan kursor ke bar untuk detail.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{/* Legend */}
<div className="mb-3 flex flex-wrap gap-4 text-[11px]">
{bars.map((b) => (
<span key={b.key} className="flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: b.color }}
/>
{b.label}
</span>
))}
</div>
{/* Chart area */}
<div className="relative overflow-x-auto">
<div className="min-w-[560px]">
<svg
viewBox={`0 0 ${Math.max(data.length * BAR_GROUP_WIDTH + 40, 200)} ${CHART_HEIGHT + 40}`}
className="w-full"
style={{ height: CHART_HEIGHT + 40 }}
>
{/* Grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const y = CHART_HEIGHT - ratio * (CHART_HEIGHT - 20) - 20;
return (
<g key={ratio}>
<line
x1={30}
y1={y}
x2={data.length * BAR_GROUP_WIDTH + 10}
y2={y}
stroke="hsl(var(--muted))"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={28}
y={y + 3}
textAnchor="end"
className="fill-muted-foreground"
fontSize={9}
>
{Math.round(ratio * maxTotal)}
</text>
</g>
);
})}
{/* Bars */}
{data.map((d, i) => {
const x = i * BAR_GROUP_WIDTH + 32;
let accumulated = 0;
return (
<g key={d.hour}>
{/* Hover target (invisible wider rect) */}
<rect
x={x - 4}
y={0}
width={BAR_GROUP_WIDTH}
height={CHART_HEIGHT}
fill="transparent"
className="cursor-crosshair"
onMouseEnter={() => {
setTooltip({ hour: d.hour, total: d.total });
setHoveredBar(d.hour);
}}
onMouseLeave={() => {
setTooltip(null);
setHoveredBar(null);
}}
/>
{/* Stacked bars */}
{bars.map((bar) => {
const val = d[bar.key];
const barH = (val / maxTotal) * (CHART_HEIGHT - 20);
const y = CHART_HEIGHT - accumulated - barH - 20;
accumulated += barH;
return val > 0 ? (
<rect
key={bar.key}
x={x + GAP}
y={y}
width={BAR_WIDTH}
height={Math.max(barH, 1)}
fill={bar.color}
rx={1.5}
className={cn(
"transition-opacity",
hoveredBar === d.hour
? "opacity-100"
: hoveredBar
? "opacity-40"
: "opacity-90",
)}
/>
) : null;
})}
{/* X-axis label */}
{i % labelInterval === 0 && (
<text
x={x + BAR_WIDTH / 2 + GAP}
y={CHART_HEIGHT - 2}
textAnchor="middle"
className="fill-muted-foreground"
fontSize={9}
>
{d.hour}
</text>
)}
</g>
);
})}
</svg>
{/* Tooltip */}
{tooltip && (
<div
className="pointer-events-none absolute top-0 z-10 rounded-lg border border-muted bg-white px-3 py-2 text-xs shadow-lg"
style={{
left: `${data.findIndex((d) => d.hour === tooltip.hour) * BAR_GROUP_WIDTH + 36}px`,
}}
>
<div className="mb-1 font-semibold text-foreground">
{tooltip.hour}
</div>
{bars.map((b) => {
const d = data.find((d) => d.hour === tooltip.hour);
const val = d?.[b.key] ?? 0;
return val > 0 ? (
<div key={b.key} className="flex items-center gap-2 text-muted-foreground">
<span
className="inline-block h-2 w-2 rounded-sm"
style={{ backgroundColor: b.color }}
/>
<span>{b.label}</span>
<span className="ml-auto font-medium tabular-nums text-foreground">
{val}
</span>
</div>
) : null;
})}
<div className="mt-1 flex items-center gap-2 border-t border-muted pt-1 text-muted-foreground">
<span>Total</span>
<span className="ml-auto font-bold tabular-nums text-foreground">
{tooltip.total}
</span>
</div>
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card className="col-span-1 flex h-65 items-center justify-center text-sm text-muted-foreground lg:col-span-2">
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-primary border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</Card>
);
}
function EmptyBox({ text }: { text: string }) {
return (
<Card className="col-span-1 flex h-65 items-center justify-center text-sm text-muted-foreground lg:col-span-2">
{text}
</Card>
);
}
@@ -1,250 +0,0 @@
import { useState } from "react";
import type { AttachmentStats } from "../../../shared/api/client";
import { cn } from "../../../shared/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../shared/ui";
interface AttachmentStatsPanelProps {
stats: AttachmentStats | null;
loading: boolean;
}
const UPLOAD_COLORS = {
uploaded: { color: "#38bdf8", label: "Uploaded" },
pending: { color: "#facc15", label: "Pending" },
failed: { color: "#f472b6", label: "Failed" },
} as const;
function UploadDonut({
uploaded,
pending,
failed,
total,
}: { uploaded: number; pending: number; failed: number; total: number }) {
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
const size = 120;
const strokeWidth = 20;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
const entries = [
{ key: "uploaded" as const, ...UPLOAD_COLORS.uploaded, value: uploaded },
{ key: "pending" as const, ...UPLOAD_COLORS.pending, value: pending },
{ key: "failed" as const, ...UPLOAD_COLORS.failed, value: failed },
].filter((e) => e.value > 0);
let cumulative = 0;
const segments = entries.map((e) => {
const offset = cumulative;
const length = (e.value / total) * circumference;
cumulative += length;
return { ...e, length, offset };
});
return (
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="hsl(var(--muted))"
strokeWidth={strokeWidth}
opacity={0.15}
/>
{segments.map((seg) => {
const isHovered = hoveredKey === seg.key;
return (
<circle
key={seg.key}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={seg.color}
strokeWidth={strokeWidth}
strokeDasharray={`${seg.length} ${circumference - seg.length}`}
strokeDashoffset={-seg.offset}
strokeLinecap="round"
className={cn(
"transition-all duration-200",
hoveredKey && !isHovered ? "opacity-30" : "opacity-100",
)}
onMouseEnter={() => setHoveredKey(seg.key)}
onMouseLeave={() => setHoveredKey(null)}
/>
);
})}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="text-lg font-bold tabular-nums leading-none">
{total}
</span>
<span className="text-[9px] text-muted-foreground mt-0.5">Total</span>
</div>
{hoveredKey && (
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md border border-muted bg-white px-2.5 py-1 text-xs shadow-lg z-10 pointer-events-none">
{(() => {
const e = entries.find((en) => en.key === hoveredKey);
if (!e) return null;
return `${e.label}: ${e.value}`;
})()}
</div>
)}
</div>
);
}
export function AttachmentStatsPanel({
stats,
loading,
}: AttachmentStatsPanelProps) {
if (loading && !stats) return <LoadingBox />;
if (!stats || stats.total_attachments === 0) {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Belum ada lampiran/media.
</CardContent>
</Card>
);
}
const uploadPct =
stats.total_attachments > 0
? Math.round((stats.uploaded / stats.total_attachments) * 100)
: 0;
const failedPct =
stats.total_attachments > 0
? Math.round((stats.failed / stats.total_attachments) * 100)
: 0;
const totalSizeMB = stats.total_size_bytes / (1024 * 1024);
const metricCards = [
{ label: "Total Media", value: formatNum(stats.total_attachments), accent: "text-foreground" },
{ label: "Upload Success", value: `${uploadPct}%`, accent: "text-primary" },
{ label: "Gagal Upload", value: `${failedPct}%`, accent: "text-accent" },
{ label: "Total Ukuran", value: `${totalSizeMB.toFixed(1)} MB`, accent: "text-muted-foreground" },
{ label: "Pengupload", value: formatNum(stats.unique_uploaders), accent: "text-primary" },
];
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<span className="text-lg">🖼</span>
Statistik Media
</CardTitle>
<CardDescription className="text-xs">
{stats.top_mime_type ? (
<>
Upload status media tipe dominan:{" "}
<span className="font-medium text-primary">
{stats.top_mime_type}
</span>
</>
) : (
"Upload status media di semua channel."
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 mb-4">
{metricCards.map((c) => (
<div
key={c.label}
className="rounded-lg border border-muted/50 bg-white p-3"
>
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{c.label}
</div>
<div
className={cn(
"mt-1 font-mono text-lg font-bold tabular-nums",
c.accent,
)}
>
{c.value}
</div>
</div>
))}
</div>
{/* Donut + status bars */}
<div className="flex flex-col sm:flex-row items-center gap-6">
<UploadDonut
uploaded={stats.uploaded}
pending={stats.pending}
failed={stats.failed}
total={stats.total_attachments}
/>
{/* Status legend with inline bars */}
<div className="flex-1 w-full space-y-2">
{[
{
key: "uploaded",
...UPLOAD_COLORS.uploaded,
value: stats.uploaded,
},
{
key: "pending",
...UPLOAD_COLORS.pending,
value: stats.pending,
},
{
key: "failed",
...UPLOAD_COLORS.failed,
value: stats.failed,
},
].map((s) => {
const pct = (s.value / stats.total_attachments) * 100;
return (
<div key={s.key} className="flex items-center gap-2">
<span className="w-20 text-[10px] font-medium text-muted-foreground truncate">
{s.label}
</span>
<div className="flex-1 h-2 overflow-hidden rounded-md bg-muted/30">
<div
className="h-full rounded-md transition-all duration-300"
style={{
width: `${pct}%`,
backgroundColor: s.color,
}}
/>
</div>
<span className="w-8 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
{s.value}
</span>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
);
}
function formatNum(v: number | undefined | null): string {
if (v == null || v === 0) return "0";
return v.toLocaleString("id-ID");
}
function LoadingBox() {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -1,98 +0,0 @@
import { Activity, BarChart3 } from "lucide-react";
import { cn } from "../../../shared/lib/utils";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../shared/ui";
const TIME_RANGES = [
{ label: "1j", value: 1 },
{ label: "3j", value: 3 },
{ label: "6j", value: 6 },
{ label: "12j", value: 12 },
{ label: "24j", value: 24 },
{ label: "48j", value: 48 },
{ label: "7h", value: 168 },
];
interface ControlBarProps {
guildName: string | null;
hours: number;
isFetching: boolean;
onHoursChange: (hours: number) => void;
onRefresh: () => void;
}
export function ControlBar({
guildName,
hours,
isFetching,
onHoursChange,
onRefresh,
}: ControlBarProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<BarChart3 className="h-5 w-5 text-primary" />
Analisis Moderasi
</CardTitle>
<CardDescription>
{guildName ? (
<>
Pantau statistik, tren topik, dan aktivitas user di seluruh
channel{" "}
<span className="font-medium text-primary">{guildName}</span>.
</>
) : (
"Pantau statistik, tren topik, dan aktivitas user."
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1 rounded-lg bg-muted/30 p-0.5 ring-1 ring-muted/50">
{TIME_RANGES.map((tr) => (
<button
key={tr.value}
type="button"
onClick={() => onHoursChange(tr.value)}
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition-all",
hours === tr.value
? "bg-primary text-white shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{tr.label}
</button>
))}
</div>
<Button
onClick={onRefresh}
disabled={isFetching}
variant="outline"
size="sm"
className="ml-auto shrink-0 rounded-lg border-primary/40 text-primary hover:bg-primary/10 hover:text-primary"
>
{isFetching ? (
<span className="flex items-center gap-1.5">
<span className="h-3 w-3 animate-spin rounded-sm border-2 border-primary border-t-transparent" />
Memuat...
</span>
) : (
<span className="flex items-center gap-1.5">
<Activity className="h-3.5 w-3.5" />
Refresh
</span>
)}
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -1,189 +0,0 @@
import { useMemo, useState } from "react";
import type { HeatmapCell } from "../../../shared/api/client";
import { cn } from "../../../shared/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../shared/ui";
const DAYS = ["Sen", "Sel", "Rab", "Kam", "Jum", "Sab", "Min"];
interface HeatmapProps {
cells: HeatmapCell[];
loading: boolean;
}
export function Heatmap({ cells, loading }: HeatmapProps) {
const [tooltip, setTooltip] = useState<{
day: string;
hour: string;
total: number;
clean: number;
warned: number;
flagged: number;
} | null>(null);
const maxCount = useMemo(
() => Math.max(1, ...cells.map((c) => c.count)),
[cells],
);
if (loading && !cells?.length) return <LoadingBox />;
if (!cells?.length) return <EmptyBox />;
const cellMap = new Map<string, HeatmapCell>();
for (const c of cells) cellMap.set(`${c.dayOfWeek}-${c.hour}`, c);
function getIntensity(day: number, hour: number): number {
return (cellMap.get(`${day}-${hour}`)?.count ?? 0) / maxCount;
}
function getHeatClass(intensity: number): string {
if (intensity === 0) return "bg-muted/20";
if (intensity < 0.1) return "bg-primary/15";
if (intensity < 0.2) return "bg-primary/25";
if (intensity < 0.35) return "bg-primary/40";
if (intensity < 0.5) return "bg-primary/55";
if (intensity < 0.7) return "bg-primary/70";
return "bg-primary/85";
}
return (
<Card className="col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">
Heatmap Aktivitas
</CardTitle>
<CardDescription className="text-xs">
Hari × jam arahkan kursor ke sel untuk detail.
</CardDescription>
</CardHeader>
<CardContent className="relative">
<div className="overflow-x-auto">
<div className="min-w-[560px]">
{/* Header row */}
<div className="mb-1 flex gap-[3px] pl-8">
{Array.from({ length: 24 }, (_, h) => (
<div
key={h}
className="flex-1 text-center text-[9px] text-muted-foreground tabular-nums"
>
{h % 3 === 0 ? `${h}` : ""}
</div>
))}
</div>
{/* Rows */}
{DAYS.map((dayLabel, d) => (
<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">
{dayLabel}
</div>
{Array.from({ length: 24 }, (_, h) => {
const intensity = getIntensity(d, h);
const cell = cellMap.get(`${d}-${h}`);
const count = cell?.count ?? 0;
return (
<div
key={h}
className={cn(
"flex-1 rounded-md aspect-square border border-muted/30 transition-all duration-150",
getHeatClass(intensity),
count > 0
? "cursor-pointer hover:ring-2 hover:ring-primary/50 hover:scale-110"
: "",
)}
onMouseEnter={() => {
if (count > 0) {
setTooltip({
day: dayLabel,
hour: `${h}:00`,
total: count,
clean: cell?.clean ?? 0,
warned: cell?.warned ?? 0,
flagged: cell?.flagged ?? 0,
});
}
}}
onMouseLeave={() => setTooltip(null)}
/>
);
})}
</div>
))}
</div>
</div>
{/* Tooltip */}
{tooltip && (
<div
className="pointer-events-none absolute z-10 rounded-lg border border-muted bg-white px-3 py-2 text-xs shadow-lg"
style={{
left: "50%",
top: "100%",
transform: "translateX(-50%)",
}}
>
<div className="mb-1 font-semibold text-foreground">
{tooltip.day} {tooltip.hour}
</div>
<div className="space-y-0.5 text-muted-foreground">
<div className="flex items-center justify-between gap-4">
<span>Total</span>
<span className="font-medium tabular-nums text-foreground">
{tooltip.total}
</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-primary">Clean</span>
<span className="tabular-nums">{tooltip.clean}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-yellow-600">Warned</span>
<span className="tabular-nums">{tooltip.warned}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-accent">Flagged</span>
<span className="tabular-nums">{tooltip.flagged}</span>
</div>
</div>
</div>
)}
{/* Legend */}
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>Sepi</span>
<span className="inline-block h-3 w-3 rounded-sm bg-muted/20" />
<span className="inline-block h-3 w-3 rounded-sm bg-primary/15" />
<span className="inline-block h-3 w-3 rounded-sm bg-primary/40" />
<span className="inline-block h-3 w-3 rounded-sm bg-primary/70" />
<span className="inline-block h-3 w-3 rounded-sm bg-primary/85" />
<span>Ramai</span>
</div>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card className="col-span-2">
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
function EmptyBox() {
return (
<Card className="col-span-2">
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Belum ada data heatmap.
</CardContent>
</Card>
);
}
@@ -1,159 +0,0 @@
import type { ModerationActionRecord } from "../../../shared/api/client";
import { cn } from "../../../shared/lib/utils";
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ScrollArea,
} from "../../../shared/ui";
interface ModerationActionsPanelProps {
actions: ModerationActionRecord[];
loading: boolean;
}
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
delete_message: {
label: "Hapus Pesan",
color: "bg-red-100 text-red-700 border-red-200",
},
warn_user: {
label: "Peringatan",
color: "bg-yellow-100 text-yellow-700 border-yellow-200",
},
mute_user: {
label: "Mute",
color: "bg-orange-100 text-orange-700 border-orange-200",
},
kick_user: {
label: "Kick",
color: "bg-pink-100 text-pink-700 border-pink-200",
},
ban_user: {
label: "Ban",
color: "bg-accent/20 text-accent border-accent/30",
},
};
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: "Pending", color: "bg-gray-100 text-gray-600" },
completed: { label: "Selesai", color: "bg-green-100 text-green-700" },
executed: { label: "Tereksekusi", color: "bg-green-100 text-green-700" },
failed: { label: "Gagal", color: "bg-red-100 text-red-700" },
};
export function ModerationActionsPanel({
actions,
loading,
}: ModerationActionsPanelProps) {
if (loading && !actions?.length) return <LoadingBox />;
if (!actions?.length) {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Belum ada aksi moderasi.
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<span className="text-lg">🛡</span>
Aksi Moderasi
</CardTitle>
<CardDescription className="text-xs">
Riwayat tindakan moderasi yang telah diambil.
</CardDescription>
</div>
<Badge variant="secondary">{actions.length} aksi</Badge>
</div>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="max-h-[320px]">
<div className="divide-y divide-muted/30">
{actions.map((action) => {
const actionStyle = ACTION_LABELS[action.action_type] ?? {
label: action.action_type,
color: "bg-gray-100 text-gray-600",
};
const statusStyle = STATUS_LABELS[action.status] ?? {
label: action.status,
color: "bg-gray-100 text-gray-600",
};
return (
<div
key={action.id}
className="px-5 py-3 text-sm hover:bg-muted/10 transition-colors"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<Badge
variant="outline"
className={cn(
"text-[10px] px-1.5 py-0 font-semibold whitespace-nowrap border",
actionStyle.color,
)}
>
{actionStyle.label}
</Badge>
<span className="truncate text-xs font-medium text-foreground">
{action.username}
</span>
</div>
<Badge
variant="outline"
className={cn(
"text-[9px] px-1.5 py-0 shrink-0",
statusStyle.color,
)}
>
{statusStyle.label}
</Badge>
</div>
{action.reason && (
<p className="mt-1 text-[11px] text-muted-foreground line-clamp-1 pl-1">
{action.reason}
</p>
)}
{action.error && (
<p className="mt-0.5 text-[10px] text-destructive pl-1">
Error: {action.error}
</p>
)}
<div className="mt-1 text-[10px] text-muted-foreground pl-1">
{new Date(action.created_at).toLocaleString("id-ID", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -1,157 +0,0 @@
import type { ModerationBreakdown } from "../../../shared/api/client";
import { cn } from "../../../shared/lib/utils";
import { Card, CardContent, Skeleton } from "../../../shared/ui";
interface SummaryCardsProps {
messages: ModerationBreakdown | null;
activeUsersCount: number;
totalChannels: number;
loading: boolean;
}
interface CardDef {
label: string;
value: string;
accent: string;
barColor: string;
barPct?: number;
icon: string;
}
export function SummaryCards({
messages,
activeUsersCount,
totalChannels,
loading,
}: SummaryCardsProps) {
const avgPerHour = messages
? Math.round(messages.total / Math.max(1, 24))
: 0;
const cleanPct =
messages && messages.total > 0
? Math.round((messages.clean / messages.total) * 100)
: 0;
const flaggedPct =
messages && messages.total > 0
? Math.round((messages.flagged / messages.total) * 100)
: 0;
const warnedPct =
messages && messages.total > 0
? Math.round((messages.warned / messages.total) * 100)
: 0;
const cards: CardDef[] = [
{
label: "Total Pesan",
value: formatNum(messages?.total),
accent: "text-foreground",
barColor: "bg-primary",
barPct: 100,
icon: "💬",
},
{
label: "Rata-rata/jam",
value: formatNum(avgPerHour),
accent: "text-muted-foreground",
barColor: "bg-primary/60",
barPct: avgPerHour > 0 ? Math.min((avgPerHour / 50) * 100, 100) : 0,
icon: "📊",
},
{
label: "Clean",
value: cleanPct > 0 ? `${cleanPct}%` : "—",
accent: "text-primary",
barColor: "bg-primary",
barPct: cleanPct,
icon: "✅",
},
{
label: "Warned",
value: warnedPct > 0 ? `${warnedPct}%` : "—",
accent: "text-yellow-600",
barColor: "bg-yellow-400",
barPct: warnedPct,
icon: "⚠️",
},
{
label: "Flagged",
value: flaggedPct > 0 ? `${flaggedPct}%` : "—",
accent: "text-accent",
barColor: "bg-accent",
barPct: flaggedPct,
icon: "🚩",
},
{
label: "Pending",
value: formatNum(messages?.pending),
accent: "text-muted-foreground",
barColor: "bg-muted-foreground/40",
barPct:
messages && messages.total > 0
? Math.round((messages.pending / messages.total) * 100)
: 0,
icon: "⏳",
},
{
label: "User Aktif",
value: formatNum(activeUsersCount),
accent: "text-primary",
barColor: "bg-primary",
barPct: activeUsersCount > 0 ? Math.min((activeUsersCount / 20) * 100, 100) : 0,
icon: "👤",
},
{
label: "Channel",
value: formatNum(totalChannels),
accent: "text-primary",
barColor: "bg-primary",
barPct: totalChannels > 0 ? Math.min((totalChannels / 20) * 100, 100) : 0,
icon: "📡",
},
];
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-8">
{cards.map((card) => (
<Card key={card.label} className="overflow-hidden">
<CardContent className="p-3">
<div className="flex items-center gap-1.5 mb-1.5">
<span className="flex h-5 w-5 items-center justify-center text-[11px]">
{card.icon}
</span>
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{card.label}
</div>
</div>
<div
className={cn(
"font-mono text-lg font-bold tabular-nums",
loading ? "opacity-30" : card.accent,
)}
>
{loading ? (
<Skeleton className="h-7 w-12 mt-1" />
) : (
card.value
)}
</div>
{/* Mini bar indicator */}
{card.barPct != null && !loading && (
<div className="mt-1.5 h-1 overflow-hidden rounded-sm bg-muted/30">
<div
className={cn("h-full rounded-sm transition-all duration-500", card.barColor)}
style={{ width: `${card.barPct}%` }}
/>
</div>
)}
</CardContent>
</Card>
))}
</div>
);
}
function formatNum(v: number | undefined | null): string {
if (v == null || v === 0) return "—";
return v.toLocaleString("id-ID");
}
@@ -1,102 +0,0 @@
import { Flame } from "lucide-react";
import type { TopicTrend } from "../../../shared/api/client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ScrollArea,
} from "../../../shared/ui";
interface TopicListProps {
topics: TopicTrend[];
loading: boolean;
}
const TOPIC_COLORS = [
"from-primary to-sky-300",
"from-accent to-pink-300",
"from-orange-400 to-yellow-300",
"from-emerald-400 to-teal-300",
"from-violet-400 to-purple-300",
];
export function TopicList({ topics, loading }: TopicListProps) {
if (loading && !topics?.length) return <LoadingBox />;
if (!topics?.length) {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Topik akan muncul setelah AI selesai menganalisis.
</CardContent>
</Card>
);
}
const maxCount = Math.max(...topics.map((t) => t.count), 1);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Flame className="h-4 w-4 text-primary" />
Topik Trending
</CardTitle>
<CardDescription className="text-xs">
Yang paling ramai dibicarakan.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="max-h-[260px]">
<div className="divide-y divide-muted/30">
{topics.map((topic, i) => {
const colorClass =
TOPIC_COLORS[i % TOPIC_COLORS.length];
return (
<div
key={topic.topic}
className="flex items-center gap-3 px-5 py-2.5 text-sm border-l-2"
style={{
borderLeftColor: `hsl(${199 + i * 35}, 80%, 50%)`,
}}
>
<span className="w-5 shrink-0 text-right font-mono text-[10px] text-muted-foreground">
{i + 1}
</span>
<span className="flex-1 truncate font-medium text-xs">
{topic.topic}
</span>
<div className="flex items-center gap-2">
<div className="h-2 w-16 overflow-hidden rounded-md bg-muted/30">
<div
className={`h-full rounded-md bg-gradient-to-r ${colorClass}`}
style={{
width: `${(topic.count / maxCount) * 100}%`,
}}
/>
</div>
<span className="w-8 text-right font-mono text-xs tabular-nums text-muted-foreground">
{topic.count}
</span>
</div>
</div>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -1,304 +0,0 @@
import { useState } from "react";
import type { TrendBucket } from "../../../shared/api/client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../shared/ui";
import { cn } from "../../../shared/lib/utils";
interface TrendChartProps {
trend: TrendBucket[];
loading: boolean;
}
const LINE_COLORS: Array<{
key: keyof Omit<TrendBucket, "date">;
color: string;
label: string;
dash?: string;
}> = [
{ key: "count", color: "#38bdf8", label: "Total" },
{ key: "clean", color: "#34d399", label: "Clean" },
{ key: "flagged", color: "#f472b6", label: "Flagged" },
{ key: "warned", color: "#facc15", label: "Warned" },
{ key: "error", color: "#fb923c", label: "Error", dash: "4 3" },
];
export function TrendChart({ trend, loading }: TrendChartProps) {
const [tooltip, setTooltip] = useState<{
date: string;
values: Array<{ key: string; label: string; value: number; color: string }>;
} | null>(null);
if (loading && !trend?.length) return <LoadingBox />;
if (!trend?.length) return null;
const CHART_HEIGHT = 200;
const CHART_PADDING = { top: 10, right: 16, bottom: 30, left: 40 };
const chartW = Math.max((trend.length - 1) * 64, 200);
const plotW = chartW - CHART_PADDING.left - CHART_PADDING.right;
const plotH = CHART_HEIGHT - CHART_PADDING.top - CHART_PADDING.bottom;
const allValues = trend.flatMap((d) =>
LINE_COLORS.map((l) => Number(d[l.key] ?? 0)),
);
const maxValue = Math.max(...allValues, 1);
function getX(index: number): number {
if (trend.length <= 1) return CHART_PADDING.left;
return (
CHART_PADDING.left +
(index / (trend.length - 1)) * plotW
);
}
function getY(value: number): number {
return CHART_PADDING.top + plotH - (value / maxValue) * plotH;
}
function buildLinePath(
data: TrendBucket[],
key: keyof Omit<TrendBucket, "date">,
): string {
const points = data.map((d, i) => ({
x: getX(i),
y: getY(Number(d[key] ?? 0)),
}));
if (points.length === 0) return "";
const segments: string[] = [`M ${points[0].x} ${points[0].y}`];
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const cx = (prev.x + curr.x) / 2;
segments.push(`Q ${cx} ${prev.y} ${curr.x} ${curr.y}`);
}
return segments.join(" ");
}
function buildAreaPath(
data: TrendBucket[],
key: keyof Omit<TrendBucket, "date">,
): string {
const line = buildLinePath(data, key);
if (!line) return "";
const first = getX(0);
const last = getX(data.length - 1);
const bottom = CHART_PADDING.top + plotH;
return `${line} L ${last} ${bottom} L ${first} ${bottom} Z`;
}
const totalMessages = trend.reduce((sum, d) => sum + d.count, 0);
return (
<Card className="col-span-3">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Tren Harian</CardTitle>
<CardDescription className="text-xs">
Volume pesan per hari arahkan kursor ke titik untuk detail.
</CardDescription>
</CardHeader>
<CardContent>
{/* Legend */}
<div className="mb-3 flex flex-wrap gap-4 text-[11px]">
{LINE_COLORS.map((l) => (
<span key={l.key} className="flex items-center gap-1.5">
<svg width="14" height="3" className="overflow-visible">
<line
x1="0"
y1="1.5"
x2="14"
y2="1.5"
stroke={l.color}
strokeWidth={2}
strokeDasharray={l.dash ?? "none"}
strokeLinecap="round"
/>
</svg>
{l.label}
</span>
))}
</div>
<div className="overflow-hidden rounded-xl border border-muted/50 bg-white/60 p-4">
<div className="mb-3 flex items-center justify-between text-[11px] text-muted-foreground">
<span>
Rangkuman{" "}
{trend.length > 1
? `${trend.length} hari terakhir`
: "hari ini"}
</span>
<span className="font-medium tabular-nums">
{totalMessages} total pesan
</span>
</div>
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${chartW} ${CHART_HEIGHT}`}
className="w-full"
style={{ height: CHART_HEIGHT }}
>
{/* Grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const y = getY(ratio * maxValue);
return (
<g key={ratio}>
<line
x1={CHART_PADDING.left}
y1={y}
x2={chartW - CHART_PADDING.right}
y2={y}
stroke="hsl(var(--muted))"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={CHART_PADDING.left - 6}
y={y + 3}
textAnchor="end"
className="fill-muted-foreground"
fontSize={9}
>
{Math.round(ratio * maxValue)}
</text>
</g>
);
})}
{/* Area fills */}
{LINE_COLORS.filter((l) => l.key === "count" || l.key === "flagged").map((l) => (
<path
key={`area-${l.key}`}
d={buildAreaPath(trend, l.key)}
fill={l.color}
opacity={0.07}
/>
))}
{/* Lines */}
{LINE_COLORS.map((l) => (
<path
key={`line-${l.key}`}
d={buildLinePath(trend, l.key)}
fill="none"
stroke={l.color}
strokeWidth={l.key === "count" ? 2.5 : 1.5}
strokeDasharray={l.dash ?? "none"}
strokeLinejoin="round"
strokeLinecap="round"
className="transition-opacity"
/>
))}
{/* Interactive dots */}
{trend.map((d, i) => {
const x = getX(i);
const y = getY(d.count);
const isActive = tooltip?.date === d.date;
return (
<g key={d.date}>
{/* Invisible hit area */}
<rect
x={x - 28}
y={CHART_PADDING.top}
width={56}
height={plotH}
fill="transparent"
className="cursor-crosshair"
onMouseEnter={() => {
setTooltip({
date: d.date,
values: LINE_COLORS.map((l) => ({
key: l.key,
label: l.label,
value: Number(d[l.key] ?? 0),
color: l.color,
})),
});
}}
onMouseLeave={() => setTooltip(null)}
/>
{/* Dot */}
<circle
cx={x}
cy={y}
r={isActive ? 5 : 2.5}
fill={isActive ? "#38bdf8" : "#38bdf8"}
stroke="white"
strokeWidth={isActive ? 2 : 0}
className={cn(
"transition-all",
isActive ? "opacity-100" : "opacity-70",
)}
/>
{/* Date label */}
<text
x={x}
y={CHART_PADDING.top + plotH + 16}
textAnchor="middle"
className="fill-muted-foreground"
fontSize={9}
>
{d.date.slice(5)}
</text>
</g>
);
})}
</svg>
{/* Tooltip */}
{tooltip && (
<div
className="pointer-events-none absolute z-10 rounded-lg border border-muted bg-white px-3 py-2 text-xs shadow-lg"
style={{
top: `${CHART_PADDING.top + 4}px`,
left: `${getX(trend.findIndex((d) => d.date === tooltip.date)) + 12}px`,
}}
>
<div className="mb-1 font-semibold text-foreground">
{tooltip.date}
</div>
{tooltip.values
.filter((v) => v.value > 0)
.map((v) => (
<div
key={v.key}
className="flex items-center gap-2 text-muted-foreground"
>
<svg width="8" height="8">
<circle
cx="4"
cy="4"
r="3"
fill={v.color}
/>
</svg>
<span>{v.label}</span>
<span className="ml-6 font-medium tabular-nums text-foreground">
{v.value}
</span>
</div>
))}
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card className="col-span-3">
<CardContent className="flex h-65 items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -1,139 +0,0 @@
import { Users } from "lucide-react";
import type { UserStat } from "../../../shared/api/client";
import { cn } from "../../../shared/lib/utils";
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ScrollArea,
} from "../../../shared/ui";
interface UserTableProps {
users: UserStat[];
loading: boolean;
}
export function UserTable({ users, loading }: UserTableProps) {
if (loading && !users?.length) return <LoadingBox />;
if (!users?.length) {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Belum ada aktivitas user.
</CardContent>
</Card>
);
}
const maxMsgs = Math.max(...users.map((u) => u.message_count), 1);
const medals = ["🥇", "🥈", "🥉"];
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Users className="h-4 w-4 text-primary" />
User Paling Aktif
</CardTitle>
<CardDescription className="text-xs">
Leaderboard berdasarkan jumlah pesan.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="max-h-[260px]">
<table className="w-full text-sm">
<thead>
<tr className="sticky top-0 z-10 bg-white border-b border-muted/50 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>
<th className="py-2 pr-2 font-semibold text-right">Edit</th>
<th className="py-2 pr-4 font-semibold text-right">Flag</th>
</tr>
</thead>
<tbody className="divide-y divide-muted/20">
{users.map((user, i) => (
<tr
key={user.user_id}
className={cn(
"transition-colors",
i % 2 === 0 ? "bg-white" : "bg-muted/10",
)}
>
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
{medals[i] ?? i + 1}
</td>
<td className="py-1.5 pr-2">
<div className="flex items-center gap-2">
{user.avatar_url ? (
<img
src={user.avatar_url}
alt=""
className="h-6 w-6 rounded-md ring-1 ring-muted"
loading="lazy"
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 text-[10px] font-bold text-primary">
{user.username.charAt(0).toUpperCase()}
</div>
)}
<span className="max-w-[100px] truncate text-xs font-medium">
{user.username}
</span>
</div>
</td>
<td className="py-1.5 pr-2 text-right">
<div className="flex items-center justify-end gap-1.5">
<div className="h-1.5 w-14 overflow-hidden rounded-sm bg-muted/30">
<div
className="h-full rounded-sm bg-gradient-to-r from-primary to-sky-300"
style={{
width: `${(user.message_count / maxMsgs) * 100}%`,
}}
/>
</div>
<span className="font-mono text-xs tabular-nums text-foreground">
{user.message_count}
</span>
</div>
</td>
<td className="py-1.5 pr-2 text-right font-mono text-[10px] text-muted-foreground tabular-nums">
{user.edited_count > 0 ? user.edited_count : "—"}
</td>
<td className="py-1.5 pr-4 text-right">
{user.flagged_count > 0 ? (
<Badge
variant="destructive"
className="text-[9px] px-1 py-0"
>
{user.flagged_count}
</Badge>
) : (
<span className="text-[10px] text-muted-foreground">
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</ScrollArea>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -1,179 +0,0 @@
import { Siren } from "lucide-react";
import type { ViolatorStat } from "../../../shared/api/client";
import { cn } from "../../../shared/lib/utils";
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ScrollArea,
} from "../../../shared/ui";
interface ViolatorTableProps {
users: ViolatorStat[];
loading: boolean;
}
export function ViolatorTable({ users, loading }: ViolatorTableProps) {
if (loading && !users?.length) return <LoadingBox />;
if (!users?.length) {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Tidak ada pelanggaran terdeteksi.
</CardContent>
</Card>
);
}
const maxScore = Math.max(...users.map((u) => u.violation_score), 1);
function dangerLabel(score: number) {
if (score >= 10) return { variant: "destructive" as const, text: "HIGH" };
if (score >= 5) return { variant: "warning" as const, text: "MED" };
return { variant: "secondary" as const, text: "LOW" };
}
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Siren className="h-4 w-4 text-accent" />
Pelanggar Terbanyak
</CardTitle>
<CardDescription className="text-xs">
Skor: flagged × 3 + warned. Flag terbanyak terakhir ditampilkan.
</CardDescription>
</div>
<Badge variant="destructive">{users.length} pelanggar</Badge>
</div>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="max-h-[320px]">
<table className="w-full text-sm">
<thead>
<tr className="sticky top-0 z-10 bg-white border-b border-muted/50 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">Flagged</th>
<th className="py-2 pr-2 font-semibold text-right">Warned</th>
<th className="py-2 pr-2 font-semibold text-right">Skor</th>
<th className="py-2 pr-4 font-semibold">Flag</th>
</tr>
</thead>
<tbody className="divide-y divide-muted/20">
{users.map((user, i) => {
const danger = dangerLabel(user.violation_score);
return (
<tr
key={user.user_id}
className={cn(
"transition-colors border-l-2",
i % 2 === 0 ? "bg-white" : "bg-muted/10",
danger.variant === "destructive"
? "border-l-accent/60"
: danger.variant === "warning"
? "border-l-pink-300/60"
: "border-l-pink-200/40",
)}
>
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
{i + 1}
</td>
<td className="py-1.5 pr-2">
<div className="flex items-center gap-2">
{user.avatar_url ? (
<img
src={user.avatar_url}
alt=""
className="h-6 w-6 rounded-md ring-1 ring-muted"
loading="lazy"
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-accent/10 text-[10px] font-bold text-accent">
{user.username.charAt(0).toUpperCase()}
</div>
)}
<span className="max-w-[100px] truncate text-xs font-medium">
{user.username}
</span>
<Badge
variant={danger.variant}
className="text-[9px] px-1 py-0"
>
{danger.text}
</Badge>
</div>
</td>
<td className="py-1.5 pr-2 text-right font-mono text-xs text-accent tabular-nums">
{user.flagged_count}
</td>
<td className="py-1.5 pr-2 text-right font-mono text-xs text-yellow-600 tabular-nums">
{user.warned_count > 0 ? user.warned_count : "—"}
</td>
<td className="py-1.5 pr-2 text-right">
<div className="flex items-center justify-end gap-1.5">
<div className="h-1.5 w-14 overflow-hidden rounded-sm bg-muted/30">
<div
className={cn(
"h-full rounded-sm",
user.violation_score >= 10
? "bg-gradient-to-r from-accent to-pink-400"
: user.violation_score >= 5
? "bg-gradient-to-r from-pink-400 to-pink-300"
: "bg-gradient-to-r from-pink-300 to-pink-200",
)}
style={{
width: `${(user.violation_score / maxScore) * 100}%`,
}}
/>
</div>
<span className="font-mono text-xs font-bold tabular-nums text-foreground">
{user.violation_score}
</span>
</div>
</td>
<td className="py-1.5 pr-4">
<div className="flex flex-wrap gap-1">
{user.worst_flags?.length > 0 ? (
user.worst_flags.slice(0, 3).map((flag) => (
<Badge
key={flag}
variant="outline"
className="text-[8px] px-1 py-0 border-accent/30 text-accent"
>
{flag}
</Badge>
))
) : (
<span className="text-[10px] text-muted-foreground">
</span>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</ScrollArea>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -1,184 +0,0 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useCallback, useEffect } from "react";
import type {
AIStats,
AnalyticsOverview,
AttachmentStats,
HeatmapCell,
HourlyBucket,
ModerationActionRecord,
TopicTrend,
TrendBucket,
UserStat,
ViolatorStat,
} from "../../../shared/api/client";
import {
fetchAIStats,
fetchAnalyticsOverview,
fetchAttachmentStats,
fetchHeatmap,
fetchModerationActions,
fetchTrend,
fetchViolators,
} from "../../../shared/api/client";
function analyticsKeys(
guildId: string,
channelId: string | undefined,
hours: number,
) {
const base = [guildId, channelId ?? "", hours] as const;
return {
overview: ["analytics", "overview", ...base] as const,
violators: ["analytics", "violators", ...base] as const,
trend: ["analytics", "trend", ...base] as const,
heatmap: ["analytics", "heatmap", ...base] as const,
aiStats: ["analytics", "ai-stats", ...base] as const,
attachmentStats: ["analytics", "attachment-stats", ...base] as const,
moderationActions: ["analytics", "moderation-actions", ...base] as const,
};
}
interface UseAnalyticsOptions {
guildId: string;
channelId?: string;
hours?: number;
}
export function useAnalytics({
guildId,
channelId,
hours = 24,
}: UseAnalyticsOptions) {
const keys = analyticsKeys(guildId, channelId, hours);
const overviewQuery = useQuery({
queryKey: keys.overview,
queryFn: () => fetchAnalyticsOverview({ guildId, channelId, hours }),
enabled: !!guildId,
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const violatorsQuery = useQuery({
queryKey: keys.violators,
queryFn: () => fetchViolators({ guildId, channelId, hours, limit: 20 }),
enabled: !!guildId,
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const trendQuery = useQuery({
queryKey: keys.trend,
queryFn: () => fetchTrend({ guildId, channelId, hours }),
enabled: !!guildId,
staleTime: 60_000,
placeholderData: keepPreviousData,
});
const heatmapQuery = useQuery({
queryKey: keys.heatmap,
queryFn: () => fetchHeatmap({ guildId, channelId, hours }),
enabled: !!guildId,
staleTime: 60_000,
placeholderData: keepPreviousData,
});
const aiStatsQuery = useQuery({
queryKey: keys.aiStats,
queryFn: () => fetchAIStats({ guildId, channelId, hours }),
enabled: !!guildId,
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const attachmentStatsQuery = useQuery({
queryKey: keys.attachmentStats,
queryFn: () => fetchAttachmentStats({ guildId, channelId, hours }),
enabled: !!guildId,
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const moderationActionsQuery = useQuery({
queryKey: keys.moderationActions,
queryFn: () => fetchModerationActions({ guildId, channelId, hours, limit: 50 }),
enabled: !!guildId,
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const refresh = useCallback(() => {
if (!guildId) return;
window.dispatchEvent(new CustomEvent("analytics_refresh"));
}, [guildId]);
useEffect(() => {
const handler = () => {
if (!guildId) return;
window.dispatchEvent(new CustomEvent("analytics_force_refresh"));
};
window.addEventListener("analytics_refresh", handler);
return () => window.removeEventListener("analytics_refresh", handler);
}, [refresh]);
const overview = overviewQuery.data ?? null;
const isFetching = overviewQuery.isFetching && !overviewQuery.isLoading;
const isLoading = overviewQuery.isLoading && !overviewQuery.data;
return {
overview,
isLoading,
isFetching,
error:
overviewQuery.error instanceof Error ? overviewQuery.error.message : null,
refresh,
violators: violatorsQuery.data ?? [],
violatorsLoading: violatorsQuery.isLoading && !violatorsQuery.data,
violatorsFetching: violatorsQuery.isFetching && !violatorsQuery.isLoading,
refreshViolators: () => {
if (guildId) window.dispatchEvent(new CustomEvent("analytics_refresh"));
},
trend: trendQuery.data ?? [],
trendLoading: trendQuery.isLoading && !trendQuery.data,
trendFetching: trendQuery.isFetching && !trendQuery.isLoading,
heatmap: heatmapQuery.data ?? [],
heatmapLoading: heatmapQuery.isLoading && !heatmapQuery.data,
heatmapFetching: heatmapQuery.isFetching && !heatmapQuery.isLoading,
aiStats: aiStatsQuery.data ?? null,
aiStatsLoading: aiStatsQuery.isLoading && !aiStatsQuery.data,
attachmentStats: attachmentStatsQuery.data ?? null,
attachmentStatsLoading:
attachmentStatsQuery.isLoading && !attachmentStatsQuery.data,
moderationActions: moderationActionsQuery.data ?? [],
moderationActionsLoading:
moderationActionsQuery.isLoading && !moderationActionsQuery.data,
hourly: overview?.hourly ?? ([] as HourlyBucket[]),
topics: overview?.topics ?? ([] as TopicTrend[]),
topUsers: overview?.top_users ?? ([] as UserStat[]),
messages: overview?.messages ?? null,
period: overview?.period ?? null,
activeUsersCount: overview?.active_users_count ?? 0,
totalChannels: overview?.total_channels ?? 0,
};
}
export type {
AIStats,
AnalyticsOverview,
AttachmentStats,
HeatmapCell,
HourlyBucket,
ModerationActionRecord,
TopicTrend,
TrendBucket,
UserStat,
ViolatorStat,
};
@@ -1,130 +0,0 @@
import { motion } from "framer-motion";
import { useState } from "react";
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
import { EmptyStateMascot } from "../../shared/ui";
import { ActivityChart } from "./components/ActivityChart";
import { AIDistributionPanel } from "./components/AIDistributionPanel";
import { AttachmentStatsPanel } from "./components/AttachmentStatsPanel";
import { ControlBar } from "./components/ControlBar";
import { Heatmap } from "./components/Heatmap";
import { ModerationActionsPanel } from "./components/ModerationActionsPanel";
import { SummaryCards } from "./components/SummaryCards";
import { TopicList } from "./components/TopicList";
import { TrendChart } from "./components/TrendChart";
import { UserTable } from "./components/UserTable";
import { ViolatorTable } from "./components/ViolatorTable";
import { useAnalytics } from "./hooks/useAnalytics";
interface AnalyticsPanelProps {
guildId: string;
guildName: string | null;
}
export function AnalyticsPanel({ guildId, guildName }: AnalyticsPanelProps) {
const [hours, setHours] = useState(24);
const analytics = useAnalytics({
guildId,
// No channelId — analytics for all channels in the guild
channelId: undefined,
hours,
});
const {
hourly,
topics,
topUsers,
activeUsersCount,
totalChannels,
violators,
trend,
heatmap,
aiStats,
attachmentStats,
moderationActions,
isLoading,
isFetching,
error,
refresh,
refreshViolators,
messages: analyticsMessages,
} = analytics;
const loading = isLoading && !isFetching;
if (error && !analyticsMessages) {
return (
<div className="rounded-2xl border border-red-300/40 bg-red-50/60 p-6 text-sm text-red-600 shadow-sm">
{error}
</div>
);
}
if (!guildId) {
return <EmptyStateMascot />;
}
return (
<motion.div
className="flex flex-col gap-4"
variants={cardStagger}
initial="initial"
animate="animate"
>
<motion.div variants={cardItem}>
<ControlBar
guildName={guildName}
hours={hours}
isFetching={isFetching}
onHoursChange={setHours}
onRefresh={() => {
refresh();
refreshViolators();
}}
/>
</motion.div>
<motion.div variants={cardItem}>
<SummaryCards
messages={analyticsMessages}
activeUsersCount={activeUsersCount}
totalChannels={totalChannels}
loading={loading}
/>
</motion.div>
<motion.div variants={cardItem}>
<div className="grid grid-cols-3 gap-4">
<ActivityChart hourly={hourly} loading={loading} />
<div className="col-span-1">
<TopicList topics={topics} loading={loading} />
</div>
</div>
</motion.div>
{hours >= 48 && (
<motion.div variants={cardItem}>
<TrendChart trend={trend} loading={loading} />
</motion.div>
)}
<motion.div variants={cardItem}>
<div className="grid grid-cols-3 gap-4">
<Heatmap cells={heatmap} loading={loading} />
<div className="col-span-1">
<UserTable users={topUsers} loading={loading} />
</div>
</div>
</motion.div>
<motion.div variants={cardItem}>
<div className="grid grid-cols-2 gap-4">
<AIDistributionPanel stats={aiStats} loading={loading} />
<AttachmentStatsPanel stats={attachmentStats} loading={loading} />
</div>
</motion.div>
<motion.div variants={cardItem}>
<div className="grid grid-cols-2 gap-4">
<ViolatorTable users={violators} loading={loading} />
<ModerationActionsPanel
actions={moderationActions}
loading={loading}
/>
</div>
</motion.div>
</motion.div>
);
}
@@ -225,7 +225,9 @@ function MessageRow({
{shouldShowContent ? (
<p
className={`whitespace-pre-wrap break-words text-sm leading-6 ${
message.deleted_at ? "text-muted-foreground/60" : "text-foreground/90"
message.deleted_at
? "text-muted-foreground/60"
: "text-foreground/90"
}`}
>
{renderContentWithCustomEmojis(displayContent)}
@@ -412,7 +414,9 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
{/* Message rows — divided by separator when multiple */}
<div
className={hasMultiple ? "divide-y divide-border/30 space-y-2.5" : ""}
className={
hasMultiple ? "divide-y divide-border/30 space-y-2.5" : ""
}
>
{messages.map((msg, idx) => (
<div
@@ -2,7 +2,7 @@ import { motion } from "framer-motion";
import { useEffect, useMemo, useRef } from "react";
import type { MessageRecord } from "../../../shared/api/client";
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
import { ScrollArea, EmptyStateMascot } from "../../../shared/ui";
import { EmptyStateMascot, ScrollArea } from "../../../shared/ui";
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
export interface MessageFeedProps {
@@ -96,10 +96,7 @@ export function MessageFeed({
>
{groupedMessages.map((group) => (
<motion.div key={group.messages[0].id} variants={cardItem}>
<MessageCard
messages={group.messages}
onReanalyze={onReanalyze}
/>
<MessageCard messages={group.messages} onReanalyze={onReanalyze} />
</motion.div>
))}
@@ -110,7 +110,6 @@ export function useMessages() {
}
}, []);
const reanalyzeAllErrors = useCallback(async (): Promise<number> => {
// Optimistically mark all error messages as pending
setMessages((prev) =>
+2 -286
View File
@@ -138,7 +138,7 @@ export interface UIState {
selectedTextChannel?: string;
selectedAnalyticsGuild?: string;
selectedAnalyticsChannel?: string;
activeTab?: "live" | "messages" | "analytics";
activeTab?: "live" | "messages";
isListening?: boolean;
isStreaming?: boolean;
}
@@ -147,7 +147,7 @@ export interface AppConfig {
monitorGuildId: string | null;
}
export type DashboardTab = "live" | "messages" | "analytics";
export type DashboardTab = "live" | "messages";
// ─── Messages ────────────────────────────────────────────────────────────────
@@ -277,287 +277,3 @@ export function updateUIState(patch: Partial<UIState>): Promise<UIState> {
body: JSON.stringify(patch),
});
}
// ─── Analytics ───────────────────────────────────────────────────────────────
export interface HourlyBucket {
hour: string;
count: number;
clean: number;
warned: number;
flagged: number;
error: number;
}
export interface TopicTrend {
topic: string;
count: number;
score: number;
}
export interface UserStat {
user_id: string;
username: string;
avatar_url: string | null;
message_count: number;
edited_count: number;
deleted_count: number;
flagged_count: number;
last_active: number;
}
export interface ModerationBreakdown {
total: number;
clean: number;
warned: number;
flagged: number;
error: number;
pending: number;
average_score: number;
}
export interface AnalyticsOverview {
period: { start: number; end: number };
messages: ModerationBreakdown;
hourly: HourlyBucket[];
topics: TopicTrend[];
top_users: UserStat[];
active_users_count: number;
total_channels: number;
}
export interface ViolatorStat {
user_id: string;
username: string;
avatar_url: string | null;
total_messages: number;
flagged_count: number;
warned_count: number;
violation_score: number;
worst_flags: string[];
last_violation: number;
}
export interface TrendBucket {
date: string;
count: number;
clean: number;
warned: number;
flagged: number;
error: number;
}
export interface HeatmapCell {
dayOfWeek: number;
hour: number;
count: number;
clean: number;
warned: number;
flagged: number;
}
export function fetchAnalyticsOverview(params: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<AnalyticsOverview> {
const sp = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
});
return request<AnalyticsOverview>(`/api/analytics/overview?${sp}`);
}
export function fetchHourlyStats(params: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<HourlyBucket[]> {
const sp = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
});
return request<HourlyBucket[]>(`/api/analytics/hourly?${sp}`);
}
export function fetchTopicTrends(params: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<TopicTrend[]> {
const sp = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
});
return request<TopicTrend[]>(`/api/analytics/topics?${sp}`);
}
export function fetchLeaderboard(params: {
guildId: string;
channelId?: string;
hours?: number;
limit?: number;
}): Promise<UserStat[]> {
const sp = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
...(params.limit && { limit: String(params.limit) }),
});
return request<UserStat[]>(`/api/analytics/leaderboard?${sp}`);
}
export function fetchModerationStats(params: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<ModerationBreakdown> {
const sp = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
});
return request<ModerationBreakdown>(`/api/analytics/stats?${sp}`);
}
export function fetchViolators(params: {
guildId: string;
channelId?: string;
hours?: number;
limit?: number;
}): Promise<ViolatorStat[]> {
const sp = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
...(params.limit && { limit: String(params.limit) }),
});
return request<ViolatorStat[]>(`/api/analytics/violators?${sp}`);
}
export function fetchTrend(params: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<TrendBucket[]> {
const sp = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
});
return request<TrendBucket[]>(`/api/analytics/trend?${sp}`);
}
export function fetchHeatmap(params: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<HeatmapCell[]> {
const sp = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
});
return request<HeatmapCell[]>(`/api/analytics/heatmap?${sp}`);
}
// ── New analytics types & endpoints ────────────────────────────────────────
export interface ModerationActionRecord {
id: string;
message_id: string | null;
user_id: string;
guild_id: string;
action_type: string;
reason: string | null;
executed_by: string | null;
status: string;
error: string | null;
created_at: number;
executed_at: number | null;
username: string;
content: string | null;
}
export interface AISeverityBreakdown {
none: number;
low: number;
medium: number;
high: number;
critical: number;
}
export interface AIRecommendedActions {
none: number;
monitor: number;
warn: number;
review: number;
delete: number;
escalate: number;
}
export interface AIStats {
total_analyzed: number;
severity: AISeverityBreakdown;
recommended_actions: AIRecommendedActions;
analysis_errors: number;
analysis_pending: number;
avg_confidence: number;
avg_score: number;
}
export interface AttachmentStats {
total_attachments: number;
uploaded: number;
pending: number;
failed: number;
total_size_bytes: number;
unique_uploaders: number;
top_mime_type: string | null;
}
export function fetchModerationActions(params: {
guildId: string;
channelId?: string;
hours?: number;
limit?: number;
}): Promise<ModerationActionRecord[]> {
const sp = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
...(params.limit && { limit: String(params.limit) }),
});
return request<ModerationActionRecord[]>(
`/api/analytics/moderation-actions?${sp}`,
);
}
export function fetchAIStats(params: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<AIStats> {
const sp = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
});
return request<AIStats>(`/api/analytics/ai-stats?${sp}`);
}
export function fetchAttachmentStats(params: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<AttachmentStats> {
const sp = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
});
return request<AttachmentStats>(`/api/analytics/attachment-stats?${sp}`);
}
@@ -1,8 +1,10 @@
// ─── Audio transmit hook — captures mic, encodes to PCM, sends via WebSocket ──
import { useCallback, useRef, useState } from "react";
import { getAPIURL } from "../api/client";
import { createChildLogger } from "../logger";
const SAMPLE_RATE = 24000;
const logger = createChildLogger("useAudioTransmit");
async function sendTransmitCommand(command: string): Promise<void> {
// Send via HTTP API
@@ -12,9 +14,12 @@ async function sendTransmitCommand(command: string): Promise<void> {
body: JSON.stringify({ command }),
});
if (!resp.ok) {
console.warn("HTTP command response:", resp.status, resp.statusText);
logger.warn("HTTP command response", {
status: resp.status,
statusText: resp.statusText,
});
const text = await resp.text().catch(() => resp.statusText);
console.warn("HTTP command failed:", text);
logger.warn("HTTP command failed", { error: text });
throw new Error(`HTTP ${resp.status}: ${text}`);
}
}
@@ -72,16 +77,18 @@ export function useAudioTransmit(socketRef: {
// Base64 encode
const bytes = new Uint8Array(pcmData.buffer);
let binary = '';
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
socketRef.current.send(JSON.stringify({
type: 'voice_transmit',
buffer: base64
}));
socketRef.current.send(
JSON.stringify({
type: "voice_transmit",
buffer: base64,
}),
);
};
}, [socketRef]);
@@ -1,4 +1,7 @@
import { useCallback, useState } from "react";
import { createChildLogger } from "../logger";
const logger = createChildLogger("useMascotChat");
export interface ChatContext {
messageCount: number;
@@ -28,7 +31,7 @@ export function useMascotChat(context?: ChatContext) {
const data = (await response.json()) as { response?: string };
return data.response || fallbackResponse(message, context);
} catch (error) {
console.warn("Mascot backend unavailable, using fallback", error);
logger.warn("Mascot backend unavailable, using fallback", { error });
return fallbackResponse(message, context);
}
},
@@ -53,7 +56,10 @@ function fallbackResponse(input: string, context?: ChatContext): string {
return `Ada ${context?.messageCount || 0} pesan di konteks dashboard saat ini 📊`;
}
if (lower.includes("berapa") && (lower.includes("orang") || lower.includes("user"))) {
if (
lower.includes("berapa") &&
(lower.includes("orang") || lower.includes("user"))
) {
return `Ada ${context?.activeParticipants || 0} user aktif yang terdeteksi 👥`;
}
@@ -34,7 +34,7 @@ function generateInsight(messages: MessageRecord[]): string {
// Hitung average panjang pesan
const avgLength = Math.round(
recentMessages.reduce((sum, m) => sum + (m.content?.length || 0), 0) /
recentMessages.length
recentMessages.length,
);
// Tentukan tipe percakapan
+49
View File
@@ -0,0 +1,49 @@
// Simple logger for frontend - structured logging wrapper
type LogLevel = "debug" | "info" | "warn" | "error";
interface LogContext {
[key: string]: unknown;
}
class Logger {
constructor(private context: string) {}
private log(level: LogLevel, message: string, context?: LogContext) {
const timestamp = new Date().toISOString();
const logData = {
level,
context: this.context,
message,
timestamp,
...context,
};
// Use appropriate console method
const consoleMethod = console[level] || console.log;
consoleMethod(
`[${level.toUpperCase()}] [${this.context}]`,
message,
context || "",
);
}
debug(message: string, context?: LogContext) {
this.log("debug", message, context);
}
info(message: string, context?: LogContext) {
this.log("info", message, context);
}
warn(message: string, context?: LogContext) {
this.log("warn", message, context);
}
error(message: string, context?: LogContext) {
this.log("error", message, context);
}
}
export function createChildLogger(context: string): Logger {
return new Logger(context);
}
@@ -1,11 +1,10 @@
import { BarChart3, MessageSquare, Radio } from "lucide-react";
import { MessageSquare, Radio } from "lucide-react";
import type { DashboardTab } from "../../entities/ui/types";
import { cn } from "../lib/utils";
const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [
{ id: "live", label: "Live", Icon: Radio },
{ id: "messages", label: "Messages", Icon: MessageSquare },
{ id: "analytics", label: "Analytics", Icon: BarChart3 },
];
interface MobileTabBarProps {
+4 -1
View File
@@ -1,6 +1,9 @@
// ─── Shared UI barrel export ────────────────────────────────────────────────
export { EmptyStateMascot, MascotImage } from "../../widgets/mascot/MascotImage";
export {
EmptyStateMascot,
MascotImage,
} from "../../widgets/mascot/MascotImage";
export { Badge } from "./badge";
export { Button } from "./button";
export {
+2 -4
View File
@@ -157,10 +157,8 @@ export function useDashboardSocket(handlers: WsHandlers) {
handlersRef.current.onVoiceRecordingStarted?.(d),
onVoiceRecordingStopped: (d) =>
handlersRef.current.onVoiceRecordingStopped?.(d),
onVoicePcmData: (d) =>
handlersRef.current.onVoicePcmData?.(d),
onVoiceActiveUser: (d) =>
handlersRef.current.onVoiceActiveUser?.(d),
onVoicePcmData: (d) => handlersRef.current.onVoicePcmData?.(d),
onVoiceActiveUser: (d) => handlersRef.current.onVoiceActiveUser?.(d),
};
_listeners.add(wrapper);
-2
View File
@@ -10,13 +10,11 @@ import type { WsStatus } from "../shared/ws/socket";
const titles: Record<DashboardTab, string> = {
live: "Voice, Media & Recordings",
messages: "Messages & Moderation",
analytics: "Analytics & Insights",
};
const subtitles: Record<DashboardTab, string> = {
live: "Join voice channels, play media, stream audio, and browse recordings.",
messages: "Capture, analyse, and moderate Discord messages.",
analytics: "Server moderation statistics and trends.",
};
interface HeaderProps {
+2 -3
View File
@@ -1,5 +1,5 @@
import { motion } from "framer-motion";
import { BarChart3, MessageSquare, Radio } from "lucide-react";
import { MessageSquare, Radio } from "lucide-react";
import type { DashboardTab } from "../entities/ui/types";
import type { MessageRecord } from "../shared/api/client";
import { useMascotChat } from "../shared/hooks/useMascotChat";
@@ -11,7 +11,6 @@ const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Radio }> =
[
{ id: "live", label: "Live", icon: Radio },
{ id: "messages", label: "Messages", icon: MessageSquare },
{ id: "analytics", label: "Analytics", icon: BarChart3 },
];
interface SidebarProps {
@@ -37,7 +36,7 @@ export function Sidebar({
recentMessages.map((message) => message.user_id),
).size,
lastActivity: recentMessages.length > 0 ? "Active" : "Idle",
topicsDiscussed: ["Messages", "Moderation", "Analytics"],
topicsDiscussed: ["Messages", "Moderation"],
guildId,
channelId,
});
@@ -32,7 +32,8 @@ export function MascotChatbot({
{
id: "init-1",
role: "mascot",
content: "Halo! 👋 Saya mascot mu. Ada yang bisa aku bantu tentang conversation atau analytics?",
content:
"Halo! 👋 Saya mascot mu. Ada yang bisa aku bantu tentang conversation atau analytics?",
timestamp: Date.now(),
},
]);
@@ -162,7 +163,7 @@ export function MascotChatbot({
animate={{ opacity: 1, y: 0 }}
className={cn(
"flex gap-2",
message.role === "user" ? "justify-end" : "justify-start"
message.role === "user" ? "justify-end" : "justify-start",
)}
>
{message.role === "mascot" && (
@@ -177,7 +178,7 @@ export function MascotChatbot({
"max-w-xs px-3 py-2 rounded-xl text-sm break-words",
message.role === "user"
? "bg-primary text-white rounded-br-none"
: "bg-muted text-foreground rounded-bl-none"
: "bg-muted text-foreground rounded-bl-none",
)}
>
{message.content}
@@ -204,12 +205,20 @@ export function MascotChatbot({
/>
<motion.div
animate={{ y: [0, -4, 0] }}
transition={{ duration: 0.6, repeat: Infinity, delay: 0.1 }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0.1,
}}
className="w-2 h-2 bg-muted-foreground rounded-full"
/>
<motion.div
animate={{ y: [0, -4, 0] }}
transition={{ duration: 0.6, repeat: Infinity, delay: 0.2 }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0.2,
}}
className="w-2 h-2 bg-muted-foreground rounded-full"
/>
</div>
@@ -252,15 +261,20 @@ export function MascotChatbot({
}
// Default mascot responses based on keywords
function generateMascotResponse(input: string, messages: ChatMessage[]): string {
function generateMascotResponse(
input: string,
messages: ChatMessage[],
): string {
const lowerInput = input.toLowerCase();
const responseMap: Record<string, string> = {
halo: "Halo juga! 👋 Senang ketemu kamu. Ada yang bisa aku bantu?",
terima: "Sama-sama! 😊",
apa: "Aku adalah mascot virtual yang membantu kamu memahami conversation dan analytics. Tanya aku apa saja!",
siapa: "Aku mascot mu yang baik hati! Siap membantu dengan insights tentang chat dan analytics.",
siapa:
"Aku mascot mu yang baik hati! Siap membantu dengan insights tentang chat dan analytics.",
chat: "Setiap chat yang terjadi di sini aku analisis untuk memberikan insights yang berguna. Keren kan? 😎",
pesan: "Aku bisa memberikan ringkasan tentang pesan-pesan yang dikirim, siapa yang paling aktif, dan topik populer!",
pesan:
"Aku bisa memberikan ringkasan tentang pesan-pesan yang dikirim, siapa yang paling aktif, dan topik populer!",
analitik:
"Analytics menunjukkan pola conversation, waktu aktif, partisipan utama, dan banyak hal menarik lainnya! 📊",
berapa:
@@ -1,6 +1,6 @@
import { motion } from "framer-motion";
import { MessageCircle } from "lucide-react";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
/**
* MascotImage — Anime mascot PNG from GitHub CDN