feat: add realtime Discord leaderboards
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user