feat: implement real-time leaderboard system with XP and voice channel tracking features

This commit is contained in:
2026-07-25 21:26:53 +07:00 Unverified
parent bb6740fcee
commit 3f4b670170
18 changed files with 531 additions and 178 deletions
@@ -17,9 +17,11 @@ const ALL_CHANNELS = "all";
export function ChannelSelect({
channels,
selectedChannelId,
searchQuery,
}: {
channels: DiscordVoiceChannel[];
selectedChannelId?: string;
searchQuery?: string;
}) {
const router = useRouter();
@@ -31,6 +33,7 @@ export function ChannelSelect({
onValueChange={(value) => {
const params = new URLSearchParams();
if (value !== ALL_CHANNELS) params.set("channel", value);
if (searchQuery) params.set("q", searchQuery);
const query = params.toString();
router.push(query ? `/leaderboard/vc?${query}` : "/leaderboard/vc");
}}
+7 -2
View File
@@ -32,11 +32,13 @@ type LeaderboardBoardProps =
| {
kind: "xp";
data: LeaderboardPage<XpLeaderboardRow>;
searchQuery: string;
channelId?: never;
}
| {
kind: "vc";
data: LeaderboardPage<VoiceLeaderboardRow>;
searchQuery: string;
channelId?: string;
};
@@ -115,7 +117,7 @@ function Podium({ data }: LeaderboardBoardProps) {
}
export function LeaderboardBoard(props: LeaderboardBoardProps) {
const { kind, data, channelId } = props;
const { kind, data, channelId, searchQuery } = props;
const firstRow = data.totalRows === 0
? 0
: (data.page - 1) * data.pageSize + 1;
@@ -133,7 +135,9 @@ export function LeaderboardBoard(props: LeaderboardBoardProps) {
<CardDescription>
{data.totalRows > 0
? `แสดง ${firstRow}–${lastRow} จาก ${formatInteger(String(data.totalRows))} คน`
: "ยังไม่มีข้อมูลในขอบเขตนี้"}
: searchQuery
? "ไม่พบสมาชิกที่ตรงกับคำค้นหานี้"
: "ยังไม่มีข้อมูลในขอบเขตนี้"}
</CardDescription>
</CardHeader>
<CardContent className="px-0">
@@ -181,6 +185,7 @@ export function LeaderboardBoard(props: LeaderboardBoardProps) {
page={data.page}
totalPages={data.totalPages}
channelId={channelId}
searchQuery={searchQuery}
/>
</CardFooter>
</Card>
@@ -16,9 +16,15 @@ function visiblePages(page: number, totalPages: number) {
.sort((a, b) => a - b);
}
function pageHref(path: string, page: number, channelId?: string) {
function pageHref(
path: string,
page: number,
channelId?: string,
searchQuery?: string,
) {
const params = new URLSearchParams();
if (channelId) params.set("channel", channelId);
if (searchQuery) params.set("q", searchQuery);
if (page > 1) params.set("page", String(page));
const query = params.toString();
return query ? `${path}?${query}` : path;
@@ -29,11 +35,13 @@ export function LeaderboardPagination({
page,
totalPages,
channelId,
searchQuery,
}: {
path: string;
page: number;
totalPages: number;
channelId?: string;
searchQuery?: string;
}) {
if (totalPages <= 1) return null;
const pages = visiblePages(page, totalPages);
@@ -44,7 +52,7 @@ export function LeaderboardPagination({
{page > 1 && (
<PaginationItem>
<PaginationPrevious
href={pageHref(path, page - 1, channelId)}
href={pageHref(path, page - 1, channelId, searchQuery)}
text="ก่อนหน้า"
aria-label="ไปหน้าก่อนหน้า"
/>
@@ -60,7 +68,7 @@ export function LeaderboardPagination({
)}
<PaginationItem>
<PaginationLink
href={pageHref(path, value, channelId)}
href={pageHref(path, value, channelId, searchQuery)}
isActive={value === page}
aria-label={`ไปหน้า ${value}`}
>
@@ -73,7 +81,7 @@ export function LeaderboardPagination({
{page < totalPages && (
<PaginationItem>
<PaginationNext
href={pageHref(path, page + 1, channelId)}
href={pageHref(path, page + 1, channelId, searchQuery)}
text="ถัดไป"
aria-label="ไปหน้าถัดไป"
/>
@@ -0,0 +1,69 @@
"use client";
import { Search, X } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
export function LeaderboardSearch({ query }: { query: string }) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
function navigate(nextValue: string) {
const params = new URLSearchParams(searchParams);
const trimmed = nextValue.trim();
params.delete("page");
if (trimmed) {
params.set("q", trimmed);
} else {
params.delete("q");
}
const nextQuery = params.toString();
router.push(nextQuery ? `${pathname}?${nextQuery}` : pathname);
}
return (
<form
className="flex w-full flex-col gap-2 sm:max-w-md"
onSubmit={(event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
navigate(String(formData.get("q") ?? ""));
}}
>
<Field>
<FieldLabel htmlFor="leaderboard-search">ค้นหาสมาชิก</FieldLabel>
<div className="flex gap-2">
<Input
id="leaderboard-search"
name="q"
key={query}
defaultValue={query}
placeholder="ชื่อ, username หรือ Discord ID"
autoComplete="off"
className="bg-card!"
/>
{query && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => navigate("")}
>
<X data-icon="inline-start" />
<span className="sr-only">ล้างการค้นหา</span>
</Button>
)}
<Button type="submit" variant="secondary" size="icon">
<Search data-icon="inline-start" />
<span className="sr-only">ค้นหา</span>
</Button>
</div>
</Field>
</form>
);
}
+12 -16
View File
@@ -1,8 +1,10 @@
import type { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { LeaderboardSearch } from "@/components/leaderboard/leaderboard-search";
import { LeaderboardTabs } from "@/components/leaderboard/leaderboard-tabs";
import { SpaceBackground } from "@/components/space-background";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -11,12 +13,12 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import Background from "@/public/background/space.webp";
type LeaderboardShellProps = {
active: "xp" | "vc";
title: string;
description: string;
searchQuery: string;
children: ReactNode;
};
@@ -24,28 +26,21 @@ export function LeaderboardShell({
active,
title,
description,
searchQuery,
children,
}: LeaderboardShellProps) {
return (
<main className="dark relative min-h-screen overflow-hidden bg-background text-foreground">
<div className="pointer-events-none fixed inset-0" aria-hidden>
<Image
src={Background}
alt=""
fill
priority
sizes="100vw"
className="object-cover opacity-40"
/>
</div>
<main className="relative min-h-screen overflow-hidden bg-background text-foreground">
<SpaceBackground />
<div className="relative mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 sm:px-6 lg:py-10">
<nav aria-label="ทางลัด" className="flex items-center">
<nav aria-label="ทางลัด" className="flex items-center justify-between gap-3">
<Button variant="ghost" asChild>
<Link href="/">
<ArrowLeft data-icon="inline-start" />
กลับหน้าหลัก
</Link>
</Button>
<ThemeToggle />
</nav>
<Card>
@@ -55,8 +50,9 @@ export function LeaderboardShell({
</CardTitle>
<CardDescription className="max-w-2xl">{description}</CardDescription>
</CardHeader>
<CardContent>
<LeaderboardTabs active={active} />
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<LeaderboardTabs active={active} searchQuery={searchQuery} />
<LeaderboardSearch query={searchQuery} />
</CardContent>
</Card>
+13 -2
View File
@@ -3,13 +3,24 @@
import { useRouter } from "next/navigation";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export function LeaderboardTabs({ active }: { active: "xp" | "vc" }) {
export function LeaderboardTabs({
active,
searchQuery,
}: {
active: "xp" | "vc";
searchQuery: string;
}) {
const router = useRouter();
return (
<Tabs
value={active}
onValueChange={(value) => router.push(`/leaderboard/${value}`)}
onValueChange={(value) => {
const params = new URLSearchParams();
if (searchQuery) params.set("q", searchQuery);
const query = params.toString();
router.push(query ? `/leaderboard/${value}?${query}` : `/leaderboard/${value}`);
}}
>
<TabsList className="w-full sm:w-fit" aria-label="เลือกประเภทลีดเดอร์บอร์ด">
<TabsTrigger value="xp">อันดับ XP</TabsTrigger>