feat: implement real-time leaderboard system with XP and voice channel tracking features
This commit is contained in:
@@ -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");
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user