feat(dashboard): expose trust reputation + reactions leaderboard
- GET /api/dashboard/reactions: top reacted messages (net add-remove), joined with message content, channel name, top 3 emoji breakdown - Users tab: colored trust tier badge (Trusted/Netral/At Risk/Kritis) in list rows + detail panel (was plain number badge) - Dashboard: new Reactions sub-tab with leaderboard (rank, emoji cluster, message, author, channel, count)
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Hash,
|
||||
Heart,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Users,
|
||||
@@ -13,6 +14,7 @@ import { ActivityChart } from "@/components/dashboard/activity-chart";
|
||||
import { ChannelsSection } from "@/components/dashboard/channels-section";
|
||||
import { HourlyActivityChart } from "@/components/dashboard/hourly-activity-chart";
|
||||
import { ModerationDonut } from "@/components/dashboard/moderation-donut";
|
||||
import { ReactionsSection } from "@/components/dashboard/reactions-section";
|
||||
import { StatCard } from "@/components/dashboard/stat-card";
|
||||
import { TopChannelsChart } from "@/components/dashboard/top-channels-chart";
|
||||
import { UsersSection } from "@/components/dashboard/users-section";
|
||||
@@ -21,7 +23,7 @@ import { ErrorState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useActivity, useStats } from "@/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DashboardTab = "stats" | "users" | "channels";
|
||||
type DashboardTab = "stats" | "users" | "channels" | "reactions";
|
||||
|
||||
const DAY_RANGES = [7, 14, 30] as const;
|
||||
|
||||
@@ -42,6 +44,7 @@ export default function DashboardPage() {
|
||||
{ id: "stats", label: "Stats", icon: <Hash className="size-3" /> },
|
||||
{ id: "users", label: "Users", icon: <Users className="size-3" /> },
|
||||
{ id: "channels", label: "Channels", icon: <Hash className="size-3" /> },
|
||||
{ id: "reactions", label: "Reactions", icon: <Heart className="size-3" /> },
|
||||
];
|
||||
|
||||
const moderationData = stats
|
||||
@@ -172,6 +175,8 @@ export default function DashboardPage() {
|
||||
{tab === "users" && <UsersSection />}
|
||||
|
||||
{tab === "channels" && <ChannelsSection />}
|
||||
|
||||
{tab === "reactions" && <ReactionsSection />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { Heart } from "lucide-react";
|
||||
import { GlassCard } from "@/components/glass/card";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTopReactions } from "@/hooks";
|
||||
import { renderMessageContent } from "@/lib/format";
|
||||
|
||||
function formatReactionTime(ts: number | null): string {
|
||||
if (!ts) return "";
|
||||
const diff = Date.now() - ts;
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
if (hours < 1) return "baru saja";
|
||||
if (hours < 24) return `${hours} jam lalu`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} hari lalu`;
|
||||
}
|
||||
|
||||
export function ReactionsSection() {
|
||||
const { data: reactions, isLoading, error } = useTopReactions(20);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<GlassCard variant="danger" className="p-6 text-sm">
|
||||
Gagal load reactions: {error.message}
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSkeleton count={6} height="h-16" />;
|
||||
}
|
||||
|
||||
if (!reactions || reactions.length === 0) {
|
||||
return (
|
||||
<GlassCard className="p-6">
|
||||
<EmptyState
|
||||
icon={Heart}
|
||||
title="Belum ada reaksi"
|
||||
description="Pesan dengan reaksi emoji akan muncul di sini."
|
||||
/>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{reactions.map((r, i) => (
|
||||
<GlassCard key={r.message_id} className="flex items-center gap-3 p-3">
|
||||
<span className="w-6 shrink-0 text-center font-mono text-xs text-text-secondary/50">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="flex shrink-0 gap-0.5 text-base">
|
||||
{r.top_emojis.map((e) => (
|
||||
<span
|
||||
key={`${r.message_id}-${e.emoji}`}
|
||||
title={`${e.emoji} ×${e.count}`}
|
||||
>
|
||||
{e.emoji}
|
||||
</span>
|
||||
))}
|
||||
{r.top_emojis.length === 0 && (
|
||||
<Heart className="size-4 text-text-secondary/30" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="line-clamp-1 text-xs text-text-secondary">
|
||||
{renderMessageContent(r.content, undefined) || "(tanpa teks)"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[10px] font-mono text-text-secondary/40">
|
||||
{r.username ?? "unknown"} · #
|
||||
{r.channel_name ?? r.channel_id?.slice(0, 8)} ·{" "}
|
||||
{formatReactionTime(r.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0 gap-1">
|
||||
<Heart className="size-3" />
|
||||
{r.reaction_count}
|
||||
</Badge>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,41 @@ import { useUserDetail, useUsers } from "@/hooks";
|
||||
import { renderMessageContent } from "@/lib/format";
|
||||
import type { DashboardUser } from "@/lib/types";
|
||||
|
||||
const TRUST_TIERS = [
|
||||
{
|
||||
min: 75,
|
||||
label: "Trusted",
|
||||
className: "border-green-500/40 text-green-500",
|
||||
},
|
||||
{ min: 40, label: "Netral", className: "border-sky-500/40 text-sky-500" },
|
||||
{
|
||||
min: 10,
|
||||
label: "At Risk",
|
||||
className: "border-orange-500/40 text-orange-500",
|
||||
},
|
||||
{ min: 0, label: "Kritis", className: "border-red-500/40 text-red-500" },
|
||||
] as const;
|
||||
|
||||
export function trustTier(score: number) {
|
||||
return (
|
||||
TRUST_TIERS.find((t) => score >= t.min) ??
|
||||
TRUST_TIERS[TRUST_TIERS.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
function TrustBadge({ score }: { score: number }) {
|
||||
const tier = trustTier(score);
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={tier.className}
|
||||
title={`Trust score ${score}`}
|
||||
>
|
||||
{tier.label}: {score}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersSection() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
@@ -109,7 +144,7 @@ export function UsersSection() {
|
||||
Clean: {detail.clean_count}
|
||||
</Badge>
|
||||
{detail.trust_score != null && (
|
||||
<Badge variant="outline">Trust: {detail.trust_score}</Badge>
|
||||
<TrustBadge score={detail.trust_score} />
|
||||
)}
|
||||
{detail.clean_message_streak != null && (
|
||||
<Badge variant="outline">
|
||||
@@ -200,6 +235,7 @@ function UserRow({
|
||||
{user.flagged_count > 0 && (
|
||||
<Badge variant="destructive">{user.flagged_count}</Badge>
|
||||
)}
|
||||
{user.trust_score != null && <TrustBadge score={user.trust_score} />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
useChannelDetail,
|
||||
useChannels,
|
||||
useStats,
|
||||
useTopReactions,
|
||||
useUserDetail,
|
||||
useUsers,
|
||||
} from "./use-dashboard";
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
DashboardChannelDetail,
|
||||
DashboardStats,
|
||||
DashboardUserDetail,
|
||||
TopReactedMessage,
|
||||
} from "@/lib/types";
|
||||
|
||||
export function useStats() {
|
||||
@@ -63,3 +64,9 @@ export function useChannelDetail(channelId: string | null) {
|
||||
() => dashboardApi.getChannelDetail(channelId!),
|
||||
);
|
||||
}
|
||||
|
||||
export function useTopReactions(limit = 20) {
|
||||
return useSWR<TopReactedMessage[]>(["dashboard-reactions", limit], () =>
|
||||
dashboardApi.getTopReactions(limit),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
DashboardUserDetail,
|
||||
PaginatedChannels,
|
||||
PaginatedUsers,
|
||||
TopReactedMessage,
|
||||
} from "@/lib/types";
|
||||
import { api } from "./client";
|
||||
|
||||
@@ -40,4 +41,7 @@ export const dashboardApi = {
|
||||
|
||||
getChannelDetail: (channelId: string) =>
|
||||
api.get<DashboardChannelDetail>(`/api/dashboard/channels/${channelId}`),
|
||||
|
||||
getTopReactions: (limit = 20) =>
|
||||
api.get<TopReactedMessage[]>(`/api/dashboard/reactions?limit=${limit}`),
|
||||
};
|
||||
|
||||
@@ -100,3 +100,19 @@ export interface PaginatedChannels {
|
||||
data: DashboardChannel[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface TopReactedEmoji {
|
||||
emoji: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface TopReactedMessage {
|
||||
message_id: string;
|
||||
content: string;
|
||||
username: string | null;
|
||||
channel_id: string;
|
||||
channel_name: string | null;
|
||||
created_at: number | null;
|
||||
reaction_count: number;
|
||||
top_emojis: TopReactedEmoji[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user