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
@@ -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;