Files
erika/app/leaderboard/content.tsx
T

361 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}