feat: add realtime Discord leaderboards

This commit is contained in:
2026-07-25 00:06:52 +07:00 Unverified
parent 7a9e67e283
commit bb6740fcee
39 changed files with 1917 additions and 119 deletions
@@ -0,0 +1,189 @@
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>;
channelId?: never;
}
| {
kind: "vc";
data: LeaderboardPage<VoiceLeaderboardRow>;
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 } = 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))} คน`
: "ยังไม่มีข้อมูลในขอบเขตนี้"}
</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}
/>
</CardFooter>
</Card>
</>
);
}