361 lines
11 KiB
TypeScript
361 lines
11 KiB
TypeScript
import { Fragment, type ReactNode } from "react";
|
||
import Link from "next/link";
|
||
import { ArrowLeft, DatabaseZap, RadioTower, Trophy } from "lucide-react";
|
||
import { LeaderboardSearch, LeaderboardTabs } from "./client";
|
||
import { ThemeToggle } from "@/components/theme-toggle";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardFooter,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import {
|
||
Pagination,
|
||
PaginationContent,
|
||
PaginationEllipsis,
|
||
PaginationItem,
|
||
PaginationLink,
|
||
PaginationNext,
|
||
PaginationPrevious,
|
||
} from "@/components/ui/pagination";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCaption,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
formatInteger,
|
||
formatVoiceDuration,
|
||
resolveLeaderboardName,
|
||
shortenDiscordId,
|
||
type LeaderboardPage,
|
||
type VoiceLeaderboardRow,
|
||
type XpLeaderboardRow,
|
||
} from "@/lib/leaderboard/ranking";
|
||
|
||
type LeaderboardBoardProps =
|
||
| {
|
||
kind: "xp";
|
||
data: LeaderboardPage<XpLeaderboardRow>;
|
||
searchQuery: string;
|
||
channelId?: never;
|
||
}
|
||
| {
|
||
kind: "vc";
|
||
data: LeaderboardPage<VoiceLeaderboardRow>;
|
||
searchQuery: string;
|
||
channelId?: string;
|
||
};
|
||
|
||
function rankVariant(rank: number) {
|
||
if (rank === 1) return "default";
|
||
if (rank === 2) return "secondary";
|
||
return "outline";
|
||
}
|
||
|
||
function Identity({
|
||
displayName,
|
||
username,
|
||
userId,
|
||
}: {
|
||
displayName: string | null;
|
||
username: string | null;
|
||
userId: string;
|
||
}) {
|
||
const unresolved = !displayName?.trim() && !username?.trim();
|
||
|
||
return (
|
||
<div className="flex min-w-40 flex-col gap-0.5">
|
||
<span className="font-medium">
|
||
{resolveLeaderboardName({ displayName, username, userId })}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{unresolved
|
||
? `Discord ID: ${shortenDiscordId(userId)}`
|
||
: displayName?.trim() && username?.trim()
|
||
? `@${username}`
|
||
: `Discord ID: ${shortenDiscordId(userId)}`}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Podium({ data }: LeaderboardBoardProps) {
|
||
if (data.podium.length === 0) return null;
|
||
|
||
return (
|
||
<section aria-labelledby="podium-title" className="flex flex-col gap-3">
|
||
<h2 id="podium-title" className="text-lg font-medium">สามอันดับนำ</h2>
|
||
<div className="grid gap-3 md:grid-cols-3">
|
||
{data.podium.map((row) => (
|
||
<Card
|
||
key={row.userId}
|
||
size="sm"
|
||
className={row.rank === 1 ? "md:-translate-y-1" : undefined}
|
||
>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center justify-between gap-3">
|
||
<span className="truncate">
|
||
{resolveLeaderboardName(row)}
|
||
</span>
|
||
<Badge variant={rankVariant(row.rank)}>
|
||
{row.rank === 1 && <Trophy data-icon="inline-start" />}
|
||
#{row.rank}
|
||
</Badge>
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{"level" in row
|
||
? `เลเวล ${row.level}`
|
||
: "เวลารวมในห้องเสียง"}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="text-xl font-semibold tabular-nums">
|
||
{"totalXp" in row
|
||
? `${formatInteger(row.totalXp)} XP`
|
||
: formatVoiceDuration(row.totalSeconds)}
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function visiblePages(page: number, totalPages: number) {
|
||
const pages = new Set([1, totalPages, page - 1, page, page + 1]);
|
||
return [...pages]
|
||
.filter((value) => value >= 1 && value <= totalPages)
|
||
.sort((a, b) => a - b);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function LeaderboardPagination({
|
||
path,
|
||
page,
|
||
totalPages,
|
||
channelId,
|
||
searchQuery,
|
||
}: {
|
||
path: string;
|
||
page: number;
|
||
totalPages: number;
|
||
channelId?: string;
|
||
searchQuery?: string;
|
||
}) {
|
||
if (totalPages <= 1) return null;
|
||
const pages = visiblePages(page, totalPages);
|
||
|
||
return (
|
||
<Pagination aria-label="หน้าอันดับ">
|
||
<PaginationContent>
|
||
{page > 1 && (
|
||
<PaginationItem>
|
||
<PaginationPrevious
|
||
href={pageHref(path, page - 1, channelId, searchQuery)}
|
||
text="ก่อนหน้า"
|
||
aria-label="ไปหน้าก่อนหน้า"
|
||
/>
|
||
</PaginationItem>
|
||
)}
|
||
|
||
{pages.map((value, index) => (
|
||
<Fragment key={value}>
|
||
{index > 0 && value - pages[index - 1] > 1 && (
|
||
<PaginationItem>
|
||
<PaginationEllipsis />
|
||
</PaginationItem>
|
||
)}
|
||
<PaginationItem>
|
||
<PaginationLink
|
||
href={pageHref(path, value, channelId, searchQuery)}
|
||
isActive={value === page}
|
||
aria-label={`ไปหน้า ${value}`}
|
||
>
|
||
{value}
|
||
</PaginationLink>
|
||
</PaginationItem>
|
||
</Fragment>
|
||
))}
|
||
|
||
{page < totalPages && (
|
||
<PaginationItem>
|
||
<PaginationNext
|
||
href={pageHref(path, page + 1, channelId, searchQuery)}
|
||
text="ถัดไป"
|
||
aria-label="ไปหน้าถัดไป"
|
||
/>
|
||
</PaginationItem>
|
||
)}
|
||
</PaginationContent>
|
||
</Pagination>
|
||
);
|
||
}
|
||
|
||
export function LeaderboardBoard(props: LeaderboardBoardProps) {
|
||
const { kind, data, channelId, searchQuery } = props;
|
||
const firstRow = data.totalRows === 0
|
||
? 0
|
||
: (data.page - 1) * data.pageSize + 1;
|
||
const lastRow = Math.min(data.page * data.pageSize, data.totalRows);
|
||
|
||
return (
|
||
<>
|
||
<Podium {...props} />
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>
|
||
{kind === "xp" ? "อันดับ XP ทั้งหมด" : "อันดับเวลาในห้องเสียง"}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{data.totalRows > 0
|
||
? `แสดง ${firstRow}–${lastRow} จาก ${formatInteger(String(data.totalRows))} คน`
|
||
: searchQuery
|
||
? "ไม่พบสมาชิกที่ตรงกับคำค้นหานี้"
|
||
: "ยังไม่มีข้อมูลในขอบเขตนี้"}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="px-0">
|
||
<Table>
|
||
<TableCaption>
|
||
{kind === "xp"
|
||
? "ตารางอันดับจาก XP รวมของสมาชิกเลเวล 1 ขึ้นไป"
|
||
: "ตารางอันดับจากเวลารวมในห้องเสียงและห้องเวทีปัจจุบัน"}
|
||
</TableCaption>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="pl-6">อันดับ</TableHead>
|
||
<TableHead>สมาชิก</TableHead>
|
||
{kind === "xp" && <TableHead>เลเวล</TableHead>}
|
||
<TableHead className="pr-6 text-right">
|
||
{kind === "xp" ? "XP รวม" : "เวลารวม"}
|
||
</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{data.rows.map((row) => (
|
||
<TableRow key={row.userId}>
|
||
<TableCell className="pl-6">
|
||
<Badge variant={rankVariant(row.rank)}>#{row.rank}</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Identity {...row} />
|
||
</TableCell>
|
||
{"level" in row && (
|
||
<TableCell className="tabular-nums">{row.level}</TableCell>
|
||
)}
|
||
<TableCell className="pr-6 text-right font-medium tabular-nums">
|
||
{"totalXp" in row
|
||
? formatInteger(row.totalXp)
|
||
: formatVoiceDuration(row.totalSeconds)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
<CardFooter>
|
||
<LeaderboardPagination
|
||
path={`/leaderboard/${kind}`}
|
||
page={data.page}
|
||
totalPages={data.totalPages}
|
||
channelId={channelId}
|
||
searchQuery={searchQuery}
|
||
/>
|
||
</CardFooter>
|
||
</Card>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export function LeaderboardError({ source }: { source: "database" | "discord" }) {
|
||
const Icon = source === "database" ? DatabaseZap : RadioTower;
|
||
|
||
return (
|
||
<Card role="alert">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Icon />
|
||
{source === "database"
|
||
? "โหลดข้อมูลอันดับไม่สำเร็จ"
|
||
: "โหลดรายชื่อห้อง Discord ไม่สำเร็จ"}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{source === "database"
|
||
? "ฐานข้อมูลอันดับยังไม่พร้อมใช้งาน ลองเปิดหน้านี้ใหม่อีกครั้ง"
|
||
: "ยังเลือกขอบเขตห้องเสียงไม่ได้ ลองเปิดหน้านี้ใหม่เมื่อ Discord พร้อมใช้งาน"}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="text-sm text-muted-foreground">
|
||
หน้านี้ไม่มีการดึงข้อมูลซ้ำอัตโนมัติระหว่างที่ระบบขัดข้อง
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
type LeaderboardShellProps = {
|
||
active: "xp" | "vc";
|
||
title: string;
|
||
description: string;
|
||
searchQuery: string;
|
||
children: ReactNode;
|
||
};
|
||
|
||
export function LeaderboardShell({
|
||
active,
|
||
title,
|
||
description,
|
||
searchQuery,
|
||
children,
|
||
}: LeaderboardShellProps) {
|
||
return (
|
||
<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 justify-between gap-3">
|
||
<Button variant="ghost" asChild>
|
||
<Link href="/">
|
||
<ArrowLeft data-icon="inline-start" />
|
||
กลับหน้าหลัก
|
||
</Link>
|
||
</Button>
|
||
<ThemeToggle />
|
||
</nav>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>
|
||
<h1 className="text-2xl font-semibold sm:text-3xl">{title}</h1>
|
||
</CardTitle>
|
||
<CardDescription className="max-w-2xl">{description}</CardDescription>
|
||
</CardHeader>
|
||
<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>
|
||
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|