diff --git a/services/backend/src/modules/dashboard/dashboard.repository.ts b/services/backend/src/modules/dashboard/dashboard.repository.ts index ccda3a6..c1ee021 100644 --- a/services/backend/src/modules/dashboard/dashboard.repository.ts +++ b/services/backend/src/modules/dashboard/dashboard.repository.ts @@ -395,6 +395,36 @@ export class DashboardRepository { })); } + async getTopReactors(limit: number) { + const db = getDatabase(); + const cap = Math.min(Math.max(limit || 20, 1), 50); + + // Top users by net reactions given (adds minus removes) + const result = await db.execute(sql` + SELECT + user_id, + username, + (COUNT(*) FILTER (WHERE reaction_type = 'add') + - COUNT(*) FILTER (WHERE reaction_type = 'remove'))::int AS net_count, + COUNT(*) FILTER (WHERE reaction_type = 'add')::int AS adds_count, + COUNT(DISTINCT message_id)::int AS messages_reacted, + COUNT(DISTINCT emoji)::int AS emojis_used + FROM message_reactions + GROUP BY user_id, username + ORDER BY net_count DESC + LIMIT ${cap} + `); + + return ((result.rows as Record[]) || []).map((r) => ({ + user_id: String(r.user_id), + username: String(r.username ?? "unknown"), + net_count: Number(r.net_count), + adds_count: Number(r.adds_count), + messages_reacted: Number(r.messages_reacted), + emojis_used: Number(r.emojis_used), + })); + } + 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 8920b6d..7d7c01d 100644 --- a/services/backend/src/modules/dashboard/dashboard.routes.ts +++ b/services/backend/src/modules/dashboard/dashboard.routes.ts @@ -97,5 +97,15 @@ export function createDashboardRouter(): Router { }), ); + // GET /api/dashboard/reactors — top users by reactions given + router.get( + "/dashboard/reactors", + asyncHandler(async (req: Request, res: Response) => { + const limit = Number(req.query.limit) || 20; + const reactors = await dashboardService.getTopReactors(limit); + res.json(reactors); + }), + ); + return router; } diff --git a/services/backend/src/modules/dashboard/dashboard.service.ts b/services/backend/src/modules/dashboard/dashboard.service.ts index eb8005a..68ea197 100644 --- a/services/backend/src/modules/dashboard/dashboard.service.ts +++ b/services/backend/src/modules/dashboard/dashboard.service.ts @@ -48,6 +48,11 @@ export class DashboardService { logger.debug({ limit }, "Fetching top reactions"); return dashboardRepository.getTopReactions(limit); } + + async getTopReactors(limit: number) { + logger.debug({ limit }, "Fetching top reactors"); + return dashboardRepository.getTopReactors(limit); + } } export const dashboardService = new DashboardService(); diff --git a/services/frontend/src/app/(dashboard)/messages/page.tsx b/services/frontend/src/app/(dashboard)/messages/page.tsx index 9cd97c1..d3babda 100644 --- a/services/frontend/src/app/(dashboard)/messages/page.tsx +++ b/services/frontend/src/app/(dashboard)/messages/page.tsx @@ -135,7 +135,7 @@ export default function MessagesPage() { value={selectedChannel} onValueChange={(v) => setSelectedChannel(v ?? "")} > - + diff --git a/services/frontend/src/components/dashboard/reactions-section.tsx b/services/frontend/src/components/dashboard/reactions-section.tsx index 1fd4d67..9f5b2be 100644 --- a/services/frontend/src/components/dashboard/reactions-section.tsx +++ b/services/frontend/src/components/dashboard/reactions-section.tsx @@ -1,10 +1,10 @@ "use client"; -import { Heart } from "lucide-react"; +import { Flame, Heart, SmilePlus } 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 { useTopReactions, useTopReactors } from "@/hooks"; import { renderMessageContent } from "@/lib/format"; function formatReactionTime(ts: number | null): string { @@ -18,68 +18,115 @@ function formatReactionTime(ts: number | null): string { } 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 ( - - - - ); - } + const { data: reactions, isLoading: reactionsLoading } = useTopReactions(); + const { data: reactors, isLoading: reactorsLoading } = useTopReactors(); return ( -
- {reactions.map((r, i) => ( - - - {i + 1} - -
- {r.top_emojis.map((e) => ( - +
+

+ + Top pesan paling di-reaksi +

+ {reactionsLoading ? ( + + ) : !reactions || reactions.length === 0 ? ( + + + + ) : ( +
+ {reactions.map((r, i) => ( + - {e.emoji} - + + {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} + +
))} - {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)} -

+ )} +
+ +
+

+ + Top reaktor — paling sering ngasih reaksi +

+ {reactorsLoading ? ( + + ) : !reactors || reactors.length === 0 ? ( + + + + ) : ( +
+ {reactors.map((r, i) => ( + + + {i + 1} + +
+

+ {r.username} +

+

+ {r.messages_reacted} pesan di-reaksi · {r.emojis_used} emoji + unik · {r.adds_count} total reaksi +

+
+ + + {r.net_count} + +
+ ))}
- - - {r.reaction_count} - - - ))} + )} +
); } + +export default ReactionsSection; diff --git a/services/frontend/src/components/shared/guild-selector.tsx b/services/frontend/src/components/shared/guild-selector.tsx index b5c303a..783ecb4 100644 --- a/services/frontend/src/components/shared/guild-selector.tsx +++ b/services/frontend/src/components/shared/guild-selector.tsx @@ -1,6 +1,6 @@ "use client"; -import { AlertCircle, RefreshCw } from "lucide-react"; +import { AlertCircle, RefreshCw, Server } from "lucide-react"; import { useEffect, useRef } from "react"; import { Badge } from "@/components/ui/badge"; @@ -93,7 +93,7 @@ export function GuildSelector({ Guild { - onGuildChange(v); - onChannelChange(""); - }} - > - - - - - {guilds.map((g) => ( - - {g.name} - - ))} - - - +
+ {/* Guild select */} +
+ + +
+ + {/* Channel select */} +
+ + +
); diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index cd7967c..56b9960 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -4,6 +4,7 @@ export { useChannelDetail, useChannels, useStats, + useTopReactors, useTopReactions, useUserDetail, useUsers, diff --git a/services/frontend/src/hooks/use-dashboard.ts b/services/frontend/src/hooks/use-dashboard.ts index de4a5d8..6e6a42f 100644 --- a/services/frontend/src/hooks/use-dashboard.ts +++ b/services/frontend/src/hooks/use-dashboard.ts @@ -7,6 +7,7 @@ import type { DashboardStats, DashboardUserDetail, TopReactedMessage, + TopReactor, } from "@/lib/types"; export function useStats() { @@ -65,8 +66,14 @@ export function useChannelDetail(channelId: string | null) { ); } -export function useTopReactions(limit = 20) { - return useSWR(["dashboard-reactions", limit], () => - dashboardApi.getTopReactions(limit), +export function useTopReactions() { + return useSWR(["dashboard-reactions"], () => + dashboardApi.getTopReactions(20), + ); +} + +export function useTopReactors() { + return useSWR(["dashboard-reactors"], () => + dashboardApi.getTopReactors(20), ); } diff --git a/services/frontend/src/lib/api/dashboard.ts b/services/frontend/src/lib/api/dashboard.ts index 25fdab2..d3a01da 100644 --- a/services/frontend/src/lib/api/dashboard.ts +++ b/services/frontend/src/lib/api/dashboard.ts @@ -6,6 +6,7 @@ import type { PaginatedChannels, PaginatedUsers, TopReactedMessage, + TopReactor, } from "@/lib/types"; import { api } from "./client"; @@ -44,4 +45,7 @@ export const dashboardApi = { getTopReactions: (limit = 20) => api.get(`/api/dashboard/reactions?limit=${limit}`), + + getTopReactors: (limit = 20) => + api.get(`/api/dashboard/reactors?limit=${limit}`), }; diff --git a/services/frontend/src/lib/types/dashboard.ts b/services/frontend/src/lib/types/dashboard.ts index 1d37c55..1b9fd69 100644 --- a/services/frontend/src/lib/types/dashboard.ts +++ b/services/frontend/src/lib/types/dashboard.ts @@ -116,3 +116,12 @@ export interface TopReactedMessage { reaction_count: number; top_emojis: TopReactedEmoji[]; } + +export interface TopReactor { + user_id: string; + username: string; + net_count: number; + adds_count: number; + messages_reacted: number; + emojis_used: number; +}