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:
asepharyana
2026-08-05 11:29:56 +07:00
parent f999be4fa0
commit 9a2fa999bf
12 changed files with 288 additions and 110 deletions
@@ -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) { async getUserDetail(userId: string) {
const db = getDatabase(); 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; return router;
} }
@@ -48,6 +48,11 @@ export class DashboardService {
logger.debug({ limit }, "Fetching top reactions"); logger.debug({ limit }, "Fetching top reactions");
return dashboardRepository.getTopReactions(limit); return dashboardRepository.getTopReactions(limit);
} }
async getTopReactors(limit: number) {
logger.debug({ limit }, "Fetching top reactors");
return dashboardRepository.getTopReactors(limit);
}
} }
export const dashboardService = new DashboardService(); export const dashboardService = new DashboardService();
@@ -135,7 +135,7 @@ export default function MessagesPage() {
value={selectedChannel} value={selectedChannel}
onValueChange={(v) => setSelectedChannel(v ?? "")} 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" /> <SelectValue placeholder="All channels" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1,10 +1,10 @@
"use client"; "use client";
import { Heart } from "lucide-react"; import { Flame, Heart, SmilePlus } from "lucide-react";
import { GlassCard } from "@/components/glass/card"; import { GlassCard } from "@/components/glass/card";
import { EmptyState, LoadingSkeleton } from "@/components/shared"; import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useTopReactions } from "@/hooks"; import { useTopReactions, useTopReactors } from "@/hooks";
import { renderMessageContent } from "@/lib/format"; import { renderMessageContent } from "@/lib/format";
function formatReactionTime(ts: number | null): string { function formatReactionTime(ts: number | null): string {
@@ -18,68 +18,115 @@ function formatReactionTime(ts: number | null): string {
} }
export function ReactionsSection() { export function ReactionsSection() {
const { data: reactions, isLoading, error } = useTopReactions(20); const { data: reactions, isLoading: reactionsLoading } = useTopReactions();
const { data: reactors, isLoading: reactorsLoading } = useTopReactors();
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 ( return (
<div className="space-y-2"> <div className="space-y-4">
{reactions.map((r, i) => ( <div>
<GlassCard key={r.message_id} className="flex items-center gap-3 p-3"> <h3 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary/50">
<span className="w-6 shrink-0 text-center font-mono text-xs text-text-secondary/50"> <Heart className="size-3" />
{i + 1} Top pesan paling di-reaksi
</span> </h3>
<div className="flex shrink-0 gap-0.5 text-base"> {reactionsLoading ? (
{r.top_emojis.map((e) => ( <LoadingSkeleton count={5} height="h-16" />
<span ) : !reactions || reactions.length === 0 ? (
key={`${r.message_id}-${e.emoji}`} <GlassCard className="p-6">
title={`${e.emoji} ×${e.count}`} <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 className="w-6 shrink-0 text-center font-mono text-xs text-text-secondary/50">
</span> {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>
<div className="min-w-0 flex-1"> )}
<p className="line-clamp-1 text-xs text-text-secondary"> </div>
{renderMessageContent(r.content, undefined) || "(tanpa teks)"}
</p> <div>
<p className="mt-0.5 truncate text-[10px] font-mono text-text-secondary/40"> <h3 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary/50">
{r.username ?? "unknown"} · # <Flame className="size-3" />
{r.channel_name ?? r.channel_id?.slice(0, 8)} ·{" "} Top reaktor paling sering ngasih reaksi
{formatReactionTime(r.created_at)} </h3>
</p> {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> </div>
<Badge variant="secondary" className="shrink-0 gap-1"> )}
<Heart className="size-3" /> </div>
{r.reaction_count}
</Badge>
</GlassCard>
))}
</div> </div>
); );
} }
export default ReactionsSection;
@@ -1,6 +1,6 @@
"use client"; "use client";
import { AlertCircle, RefreshCw } from "lucide-react"; import { AlertCircle, RefreshCw, Server } from "lucide-react";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -93,7 +93,7 @@ export function GuildSelector({
Guild Guild
</Badge> </Badge>
<Select value={value} onValueChange={(v) => v && onChange(v)}> <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…"> <SelectValue placeholder="Select a guild…">
{guilds.find((g) => g.id === value)?.name} {guilds.find((g) => g.id === value)?.name}
</SelectValue> </SelectValue>
@@ -101,7 +101,19 @@ export function GuildSelector({
<SelectContent> <SelectContent>
{guilds.map((g) => ( {guilds.map((g) => (
<SelectItem key={g.id} value={g.id}> <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> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -40,7 +40,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -1,5 +1,6 @@
"use client"; "use client";
import { Headphones, Server, Volume2 } from "lucide-react";
import { GlassCard } from "@/components/glass/card"; import { GlassCard } from "@/components/glass/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -90,50 +91,102 @@ export function VoiceConnectionCard({
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Select {/* Guild select */}
value={selectedGuild} <div className="space-y-1.5">
onValueChange={(v) => { <label
onGuildChange(v); htmlFor="voice-guild-select"
onChannelChange(""); className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-secondary/50"
}} >
> <Server className="size-3" />
<SelectTrigger className="h-8 glass border-glass-border text-xs"> Server / Guild
<SelectValue placeholder="Select guild" /> </label>
</SelectTrigger> <Select
<SelectContent> value={selectedGuild}
{guilds.map((g) => ( onValueChange={(v) => {
<SelectItem key={g.id} value={g.id}> onGuildChange(v);
{g.name} onChannelChange("");
</SelectItem> }}
))} >
</SelectContent> <SelectTrigger id="voice-guild-select" className="h-10">
</Select> <SelectValue placeholder="Pilih server…">
<Select {guilds.find((g) => g.id === selectedGuild)?.name}
value={selectedChannel} </SelectValue>
onValueChange={onChannelChange} </SelectTrigger>
disabled={!selectedGuild} <SelectContent>
> {guilds.map((g) => (
<SelectTrigger className="h-8 glass border-glass-border text-xs"> <SelectItem key={g.id} value={g.id}>
<SelectValue placeholder="Select channel" /> <span className="flex items-center gap-2">
</SelectTrigger> {g.icon ? (
<SelectContent> // biome-ignore lint/performance/noImgElement: guild icon is a remote Discord CDN URL
{voiceChannels.map((c) => ( <img
<SelectItem src={g.icon}
key={c.id} alt=""
value={c.id} className="size-4 rounded-full object-cover"
disabled={c.joinable === false} />
> ) : (
{c.joinable === false ? `${c.name} (no akses)` : c.name} <Server className="size-4 text-muted-foreground" />
</SelectItem> )}
))} <span className="line-clamp-1">{g.name}</span>
{voiceChannels.length === 0 && ( </span>
<div className="px-3 py-2 text-xs text-text-secondary/60"> </SelectItem>
Tidak ada voice channel ))}
</div> {guilds.length === 0 && (
)} <div className="px-3 py-2 text-xs text-text-secondary/60">
</SelectContent> Tidak ada server pastikan gateway Discord terhubung.
</Select> </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> </div>
</GlassCard> </GlassCard>
); );
+1
View File
@@ -4,6 +4,7 @@ export {
useChannelDetail, useChannelDetail,
useChannels, useChannels,
useStats, useStats,
useTopReactors,
useTopReactions, useTopReactions,
useUserDetail, useUserDetail,
useUsers, useUsers,
+10 -3
View File
@@ -7,6 +7,7 @@ import type {
DashboardStats, DashboardStats,
DashboardUserDetail, DashboardUserDetail,
TopReactedMessage, TopReactedMessage,
TopReactor,
} from "@/lib/types"; } from "@/lib/types";
export function useStats() { export function useStats() {
@@ -65,8 +66,14 @@ export function useChannelDetail(channelId: string | null) {
); );
} }
export function useTopReactions(limit = 20) { export function useTopReactions() {
return useSWR<TopReactedMessage[]>(["dashboard-reactions", limit], () => return useSWR<TopReactedMessage[]>(["dashboard-reactions"], () =>
dashboardApi.getTopReactions(limit), dashboardApi.getTopReactions(20),
);
}
export function useTopReactors() {
return useSWR<TopReactor[]>(["dashboard-reactors"], () =>
dashboardApi.getTopReactors(20),
); );
} }
@@ -6,6 +6,7 @@ import type {
PaginatedChannels, PaginatedChannels,
PaginatedUsers, PaginatedUsers,
TopReactedMessage, TopReactedMessage,
TopReactor,
} from "@/lib/types"; } from "@/lib/types";
import { api } from "./client"; import { api } from "./client";
@@ -44,4 +45,7 @@ export const dashboardApi = {
getTopReactions: (limit = 20) => getTopReactions: (limit = 20) =>
api.get<TopReactedMessage[]>(`/api/dashboard/reactions?limit=${limit}`), 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; reaction_count: number;
top_emojis: TopReactedEmoji[]; top_emojis: TopReactedEmoji[];
} }
export interface TopReactor {
user_id: string;
username: string;
net_count: number;
adds_count: number;
messages_reacted: number;
emojis_used: number;
}