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:
co-authored by
Claude Opus 4.8
parent
d0d9e1669e
commit
4becf0d6f1
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user