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) {
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export {
|
|||||||
useChannelDetail,
|
useChannelDetail,
|
||||||
useChannels,
|
useChannels,
|
||||||
useStats,
|
useStats,
|
||||||
|
useTopReactors,
|
||||||
useTopReactions,
|
useTopReactions,
|
||||||
useUserDetail,
|
useUserDetail,
|
||||||
useUsers,
|
useUsers,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user