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:
asepharyana
2026-08-05 10:20:12 +07:00
parent 2f51f94610
commit a309570d29
10 changed files with 235 additions and 2 deletions
@@ -331,6 +331,70 @@ export class DashboardRepository {
};
}
async getTopReactions(limit: number) {
const db = getDatabase();
const cap = Math.min(Math.max(limit || 20, 1), 50);
// Top messages by net reactions (adds minus removes), joined to message content
const result = await db.execute(sql`
SELECT
m.id AS message_id,
m.content,
m.username,
m.channel_id,
m.created_at,
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
r.reaction_count::int
FROM (
SELECT message_id,
(COUNT(*) FILTER (WHERE reaction_type = 'add')
- COUNT(*) FILTER (WHERE reaction_type = 'remove'))::int AS reaction_count
FROM message_reactions
GROUP BY message_id
) r
JOIN messages m ON m.id = r.message_id
WHERE r.reaction_count > 0
ORDER BY r.reaction_count DESC
LIMIT ${cap}
`);
const rows = (result.rows as Record<string, unknown>[]) || [];
if (rows.length === 0) return [];
// Top emoji per message (adds only) for the breakdown
const ids = rows.map((r) => String(r.message_id));
const emojiResult = await db.execute(sql`
SELECT message_id, emoji, COUNT(*)::int AS c
FROM message_reactions
WHERE reaction_type = 'add' AND message_id IN (${sql.join(ids, sql`, `)})
GROUP BY message_id, emoji
ORDER BY message_id, c DESC
`);
const emojiByMessage = new Map<
string,
Array<{ emoji: string; count: number }>
>();
for (const e of emojiResult.rows as Record<string, unknown>[]) {
const mid = String(e.message_id);
const list = emojiByMessage.get(mid) ?? [];
list.push({ emoji: String(e.emoji), count: Number(e.c) });
emojiByMessage.set(mid, list);
}
return rows.map((r) => ({
message_id: String(r.message_id),
content: r.content ? String(r.content) : "",
username: r.username ? String(r.username) : null,
channel_id: String(r.channel_id),
channel_name: r.channel_name ? String(r.channel_name) : null,
created_at: r.created_at ? Number(r.created_at) : null,
reaction_count: Number(r.reaction_count),
top_emojis: (emojiByMessage.get(String(r.message_id)) ?? []).slice(0, 3),
}));
}
async getUserDetail(userId: string) {
const db = getDatabase();
@@ -87,5 +87,15 @@ export function createDashboardRouter(): Router {
}),
);
// GET /api/dashboard/reactions — top reacted messages
router.get(
"/dashboard/reactions",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 20;
const reactions = await dashboardService.getTopReactions(limit);
res.json(reactions);
}),
);
return router;
}
@@ -43,6 +43,11 @@ export class DashboardService {
logger.debug({ channelId }, "Fetching channel detail");
return dashboardRepository.getChannelDetail(channelId);
}
async getTopReactions(limit: number) {
logger.debug({ limit }, "Fetching top reactions");
return dashboardRepository.getTopReactions(limit);
}
}
export const dashboardService = new DashboardService();
@@ -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>
+1
View File
@@ -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[];
}