diff --git a/services/backend/src/modules/dashboard/dashboard.repository.ts b/services/backend/src/modules/dashboard/dashboard.repository.ts index 78bbbb7..ccda3a6 100644 --- a/services/backend/src/modules/dashboard/dashboard.repository.ts +++ b/services/backend/src/modules/dashboard/dashboard.repository.ts @@ -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[]) || []; + + 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[]) { + 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(); diff --git a/services/backend/src/modules/dashboard/dashboard.routes.ts b/services/backend/src/modules/dashboard/dashboard.routes.ts index ecd5b47..8920b6d 100644 --- a/services/backend/src/modules/dashboard/dashboard.routes.ts +++ b/services/backend/src/modules/dashboard/dashboard.routes.ts @@ -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; } diff --git a/services/backend/src/modules/dashboard/dashboard.service.ts b/services/backend/src/modules/dashboard/dashboard.service.ts index 79dc03d..eb8005a 100644 --- a/services/backend/src/modules/dashboard/dashboard.service.ts +++ b/services/backend/src/modules/dashboard/dashboard.service.ts @@ -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(); diff --git a/services/frontend/src/app/(dashboard)/dashboard/page.tsx b/services/frontend/src/app/(dashboard)/dashboard/page.tsx index f68218c..090796f 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -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: }, { id: "users", label: "Users", icon: }, { id: "channels", label: "Channels", icon: }, + { id: "reactions", label: "Reactions", icon: }, ]; const moderationData = stats @@ -172,6 +175,8 @@ export default function DashboardPage() { {tab === "users" && } {tab === "channels" && } + + {tab === "reactions" && } ); } diff --git a/services/frontend/src/components/dashboard/reactions-section.tsx b/services/frontend/src/components/dashboard/reactions-section.tsx new file mode 100644 index 0000000..1fd4d67 --- /dev/null +++ b/services/frontend/src/components/dashboard/reactions-section.tsx @@ -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 ( + + Gagal load reactions: {error.message} + + ); + } + + if (isLoading) { + return ; + } + + if (!reactions || reactions.length === 0) { + return ( + + + + ); + } + + return ( +
+ {reactions.map((r, i) => ( + + + {i + 1} + +
+ {r.top_emojis.map((e) => ( + + {e.emoji} + + ))} + {r.top_emojis.length === 0 && ( + + )} +
+
+

+ {renderMessageContent(r.content, undefined) || "(tanpa teks)"} +

+

+ {r.username ?? "unknown"} · # + {r.channel_name ?? r.channel_id?.slice(0, 8)} ·{" "} + {formatReactionTime(r.created_at)} +

+
+ + + {r.reaction_count} + +
+ ))} +
+ ); +} diff --git a/services/frontend/src/components/dashboard/users-section.tsx b/services/frontend/src/components/dashboard/users-section.tsx index a1ca36d..8af450b 100644 --- a/services/frontend/src/components/dashboard/users-section.tsx +++ b/services/frontend/src/components/dashboard/users-section.tsx @@ -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 ( + + {tier.label}: {score} + + ); +} + export function UsersSection() { const [search, setSearch] = useState(""); const [selectedId, setSelectedId] = useState(null); @@ -109,7 +144,7 @@ export function UsersSection() { Clean: {detail.clean_count} {detail.trust_score != null && ( - Trust: {detail.trust_score} + )} {detail.clean_message_streak != null && ( @@ -200,6 +235,7 @@ function UserRow({ {user.flagged_count > 0 && ( {user.flagged_count} )} + {user.trust_score != null && } diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index 4f1f75c..d06644a 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -4,6 +4,7 @@ export { useChannelDetail, useChannels, useStats, + useTopReactions, useUserDetail, useUsers, } from "./use-dashboard"; diff --git a/services/frontend/src/hooks/use-dashboard.ts b/services/frontend/src/hooks/use-dashboard.ts index bdcfd56..de4a5d8 100644 --- a/services/frontend/src/hooks/use-dashboard.ts +++ b/services/frontend/src/hooks/use-dashboard.ts @@ -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(["dashboard-reactions", limit], () => + dashboardApi.getTopReactions(limit), + ); +} diff --git a/services/frontend/src/lib/api/dashboard.ts b/services/frontend/src/lib/api/dashboard.ts index 617c6ac..25fdab2 100644 --- a/services/frontend/src/lib/api/dashboard.ts +++ b/services/frontend/src/lib/api/dashboard.ts @@ -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(`/api/dashboard/channels/${channelId}`), + + getTopReactions: (limit = 20) => + api.get(`/api/dashboard/reactions?limit=${limit}`), }; diff --git a/services/frontend/src/lib/types/dashboard.ts b/services/frontend/src/lib/types/dashboard.ts index 741bfdd..1d37c55 100644 --- a/services/frontend/src/lib/types/dashboard.ts +++ b/services/frontend/src/lib/types/dashboard.ts @@ -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[]; +}