chore(file-path): correct /profile and /leaderboard file path
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user