chore(file-path): correct /profile and /leaderboard file path

This commit is contained in:
2026-08-10 17:28:43 +07:00 Unverified
parent d038c95d35
commit 1ac594293a
16 changed files with 364 additions and 379 deletions
+360
View File
@@ -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>
);
}