195 lines
6.3 KiB
TypeScript
195 lines
6.3 KiB
TypeScript
import { Trophy } from "lucide-react";
|
||
import { LeaderboardPagination } from "@/components/leaderboard/leaderboard-pagination";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardFooter,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
</>
|
||
);
|
||
}
|