fix(voice): proper shadcn select dropdowns + top reactors leaderboard
- ui/select: trigger default w-full h-9 (was w-fit h-8 — selects rendered tiny/misaligned); callers keep size override via className - VoiceConnectionCard: labeled full-width h-10 selects (Server/Guild + Voice Channel), guild icon + name in options, channel type icon + 'no akses' tag, empty states, htmlFor/id a11y wiring - GuildSelector sidebar + messages channel filter bumped to match - Backend GET /api/dashboard/reactors: top users by net reactions given (adds-removes) + messages_reacted + emojis_used - Reactions tab: second 'Top reaktor' leaderboard panel
This commit is contained in:
@@ -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<string, unknown>[]) || []).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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -135,7 +135,7 @@ export default function MessagesPage() {
|
||||
value={selectedChannel}
|
||||
onValueChange={(v) => setSelectedChannel(v ?? "")}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-40 glass border-glass-border text-xs">
|
||||
<SelectTrigger className="h-9 w-48">
|
||||
<SelectValue placeholder="All channels" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
const { data: reactions, isLoading: reactionsLoading } = useTopReactions();
|
||||
const { data: reactors, isLoading: reactorsLoading } = useTopReactors();
|
||||
|
||||
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}`}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary/50">
|
||||
<Heart className="size-3" />
|
||||
Top pesan paling di-reaksi
|
||||
</h3>
|
||||
{reactionsLoading ? (
|
||||
<LoadingSkeleton count={5} height="h-16" />
|
||||
) : !reactions || reactions.length === 0 ? (
|
||||
<GlassCard className="p-6">
|
||||
<EmptyState
|
||||
icon={Heart}
|
||||
title="Belum ada reaksi"
|
||||
description="Pesan dengan reaksi emoji akan muncul di sini."
|
||||
/>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{reactions.map((r, i) => (
|
||||
<GlassCard
|
||||
key={r.message_id}
|
||||
className="flex items-center gap-3 p-3"
|
||||
>
|
||||
{e.emoji}
|
||||
</span>
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary/50">
|
||||
<Flame className="size-3" />
|
||||
Top reaktor — paling sering ngasih reaksi
|
||||
</h3>
|
||||
{reactorsLoading ? (
|
||||
<LoadingSkeleton count={5} height="h-14" />
|
||||
) : !reactors || reactors.length === 0 ? (
|
||||
<GlassCard className="p-6">
|
||||
<EmptyState
|
||||
icon={SmilePlus}
|
||||
title="Belum ada reaktor"
|
||||
description="User yang ngasih reaksi emoji akan muncul di sini."
|
||||
/>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{reactors.map((r, i) => (
|
||||
<GlassCard
|
||||
key={r.user_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="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium text-text-primary">
|
||||
{r.username}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[10px] font-mono text-text-secondary/40">
|
||||
{r.messages_reacted} pesan di-reaksi · {r.emojis_used} emoji
|
||||
unik · {r.adds_count} total reaksi
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0 gap-1">
|
||||
<Flame className="size-3" />
|
||||
{r.net_count}
|
||||
</Badge>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0 gap-1">
|
||||
<Heart className="size-3" />
|
||||
{r.reaction_count}
|
||||
</Badge>
|
||||
</GlassCard>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReactionsSection;
|
||||
|
||||
@@ -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
|
||||
</Badge>
|
||||
<Select value={value} onValueChange={(v) => v && onChange(v)}>
|
||||
<SelectTrigger className="h-8 w-full max-w-xs">
|
||||
<SelectTrigger className="h-10 w-full max-w-sm">
|
||||
<SelectValue placeholder="Select a guild…">
|
||||
{guilds.find((g) => g.id === value)?.name}
|
||||
</SelectValue>
|
||||
@@ -101,7 +101,19 @@ export function GuildSelector({
|
||||
<SelectContent>
|
||||
{guilds.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
<span className="flex items-center gap-2">
|
||||
{g.icon ? (
|
||||
// biome-ignore lint/performance/noImgElement: guild icon is a remote Discord CDN URL
|
||||
<img
|
||||
src={g.icon}
|
||||
alt=""
|
||||
className="size-4 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="line-clamp-1">{g.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -40,7 +40,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex w-full items-center justify-between gap-2 rounded-lg border border-input bg-transparent py-2 pr-2 pl-3 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Headphones, Server, Volume2 } from "lucide-react";
|
||||
import { GlassCard } from "@/components/glass/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -90,50 +91,102 @@ export function VoiceConnectionCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select
|
||||
value={selectedGuild}
|
||||
onValueChange={(v) => {
|
||||
onGuildChange(v);
|
||||
onChannelChange("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 glass border-glass-border text-xs">
|
||||
<SelectValue placeholder="Select guild" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{guilds.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onValueChange={onChannelChange}
|
||||
disabled={!selectedGuild}
|
||||
>
|
||||
<SelectTrigger className="h-8 glass border-glass-border text-xs">
|
||||
<SelectValue placeholder="Select channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{voiceChannels.map((c) => (
|
||||
<SelectItem
|
||||
key={c.id}
|
||||
value={c.id}
|
||||
disabled={c.joinable === false}
|
||||
>
|
||||
{c.joinable === false ? `${c.name} (no akses)` : c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{voiceChannels.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-text-secondary/60">
|
||||
Tidak ada voice channel
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Guild select */}
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="voice-guild-select"
|
||||
className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-secondary/50"
|
||||
>
|
||||
<Server className="size-3" />
|
||||
Server / Guild
|
||||
</label>
|
||||
<Select
|
||||
value={selectedGuild}
|
||||
onValueChange={(v) => {
|
||||
onGuildChange(v);
|
||||
onChannelChange("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="voice-guild-select" className="h-10">
|
||||
<SelectValue placeholder="Pilih server…">
|
||||
{guilds.find((g) => g.id === selectedGuild)?.name}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{guilds.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
{g.icon ? (
|
||||
// biome-ignore lint/performance/noImgElement: guild icon is a remote Discord CDN URL
|
||||
<img
|
||||
src={g.icon}
|
||||
alt=""
|
||||
className="size-4 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="line-clamp-1">{g.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
{guilds.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-text-secondary/60">
|
||||
Tidak ada server — pastikan gateway Discord terhubung.
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Channel select */}
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="voice-channel-select"
|
||||
className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-secondary/50"
|
||||
>
|
||||
<Volume2 className="size-3" />
|
||||
Voice Channel
|
||||
</label>
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onValueChange={onChannelChange}
|
||||
disabled={!selectedGuild}
|
||||
>
|
||||
<SelectTrigger id="voice-channel-select" className="h-10">
|
||||
<SelectValue placeholder="Pilih channel…">
|
||||
{voiceChannels.find((c) => c.id === selectedChannel)?.name}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{voiceChannels.map((c) => (
|
||||
<SelectItem
|
||||
key={c.id}
|
||||
value={c.id}
|
||||
disabled={c.joinable === false}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Headphones className="size-4 text-muted-foreground" />
|
||||
<span className="line-clamp-1">{c.name}</span>
|
||||
{c.joinable === false && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||
no akses
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
{voiceChannels.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-text-secondary/60">
|
||||
{selectedGuild
|
||||
? "Tidak ada voice channel di server ini"
|
||||
: "Pilih server dulu"}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
useChannelDetail,
|
||||
useChannels,
|
||||
useStats,
|
||||
useTopReactors,
|
||||
useTopReactions,
|
||||
useUserDetail,
|
||||
useUsers,
|
||||
|
||||
@@ -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<TopReactedMessage[]>(["dashboard-reactions", limit], () =>
|
||||
dashboardApi.getTopReactions(limit),
|
||||
export function useTopReactions() {
|
||||
return useSWR<TopReactedMessage[]>(["dashboard-reactions"], () =>
|
||||
dashboardApi.getTopReactions(20),
|
||||
);
|
||||
}
|
||||
|
||||
export function useTopReactors() {
|
||||
return useSWR<TopReactor[]>(["dashboard-reactors"], () =>
|
||||
dashboardApi.getTopReactors(20),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<TopReactedMessage[]>(`/api/dashboard/reactions?limit=${limit}`),
|
||||
|
||||
getTopReactors: (limit = 20) =>
|
||||
api.get<TopReactor[]>(`/api/dashboard/reactors?limit=${limit}`),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user