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
+1 -1
View File
@@ -5,7 +5,7 @@ import { submissions, answers } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth";
import { sse } from "@/lib/sse";
import {
+1 -1
View File
@@ -2,7 +2,7 @@ import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AdminSidebar } from "./client";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import { getSessionDiscordId } from "@/lib/auth";
import { getDiscordMemberProfile } from "@/lib/discord";
+1 -1
View File
@@ -1,5 +1,5 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import { redirect } from "next/navigation";
import { getAdminDiscordIds, getSessionDiscordId } from "@/lib/auth";
+2 -48
View File
@@ -1,51 +1,5 @@
import NextAuth, { type NextAuthOptions } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db";
import { accounts, sessions, users, verificationTokens } from "@/db/schema";
import type { SessionWithDiscord } from "@/lib/auth";
const discordIdCache = new Map<string, string>();
export const authOptions: NextAuthOptions = {
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
providers: [
DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
}),
],
callbacks: {
async session({ session, user }) {
if (session.user) {
(session as SessionWithDiscord).user!.id = user.id;
let discordId = discordIdCache.get(user.id);
if (!discordId) {
const acct = await db.query.accounts.findFirst({
where: (a, { eq, and }) =>
and(eq(a.userId, user.id), eq(a.provider, "discord")),
columns: { providerAccountId: true },
});
if (acct) {
discordId = acct.providerAccountId;
discordIdCache.set(user.id, discordId);
}
}
if (discordId) {
(session as SessionWithDiscord).user!.discordId = discordId;
}
}
return session;
},
},
secret: process.env.NEXTAUTH_SECRET,
};
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth-options";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+1 -1
View File
@@ -1,7 +1,7 @@
import { db } from "@/db";
import { FormFillerClient } from "../client";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import { notFound } from "next/navigation";
import LoginDialog from "@/components/login-dialog";
import { canAccessForm, getSessionDiscordId } from "@/lib/auth";
+1 -1
View File
@@ -1,5 +1,5 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import { FormLayoutClient, LoginButton } from "./client";
import { Metadata } from "next";
import { ThemeToggle } from "@/components/theme-toggle";
+1 -1
View File
@@ -7,7 +7,7 @@ import {
} from "@/components/ui/card";
import { HtmlDisplay } from "@/components/html-display";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import { canAccessForm, getSessionDiscordId } from "@/lib/auth";
import { RealtimeRefresh } from "@/components/realtime-refresh";
+18
View File
@@ -0,0 +1,18 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function LeaderboardLoading() {
return (
<main className="dark min-h-screen bg-background px-4 py-6 text-foreground sm:px-6 lg:py-10">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<Skeleton className="h-9 w-36" />
<Skeleton className="h-48 w-full rounded-2xl" />
<div className="grid gap-3 md:grid-cols-3">
<Skeleton className="h-36 w-full rounded-2xl" />
<Skeleton className="h-36 w-full rounded-2xl" />
<Skeleton className="h-36 w-full rounded-2xl" />
</div>
<Skeleton className="h-96 w-full rounded-2xl" />
</div>
</main>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export function GET(request: Request) {
return NextResponse.redirect(new URL("/leaderboard/xp", request.url), 307);
}
+122
View File
@@ -0,0 +1,122 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { ChannelSelect } from "@/components/leaderboard/channel-select";
import { LeaderboardBoard } from "@/components/leaderboard/leaderboard-board";
import { LeaderboardError } from "@/components/leaderboard/leaderboard-error";
import { LeaderboardShell } from "@/components/leaderboard/leaderboard-shell";
import { RealtimeRefresh } from "@/components/realtime-refresh";
import { getVoiceLeaderboardPage } from "@/lib/leaderboard/database";
import { getCurrentDiscordVoiceChannels } from "@/lib/leaderboard/discord";
import { normalizeRequestedPage } from "@/lib/leaderboard/ranking";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "อันดับเวลาในห้องเสียง",
description: "ดูอันดับเวลารวมในห้องเสียงและห้องเวที Discord ปัจจุบันของ Erika!",
alternates: {
canonical: "/leaderboard/vc",
},
};
type VoiceLeaderboardPageProps = {
searchParams: Promise<{
channel?: string | string[];
page?: string | string[];
}>;
};
function voiceLeaderboardUrl(page: number, channelId?: string) {
const params = new URLSearchParams();
if (channelId) params.set("channel", channelId);
if (page > 1) params.set("page", String(page));
const query = params.toString();
return query ? `/leaderboard/vc?${query}` : "/leaderboard/vc";
}
export default async function VoiceLeaderboardPage({
searchParams,
}: VoiceLeaderboardPageProps) {
const query = await searchParams;
const requestedPage = normalizeRequestedPage(query.page);
let channels;
try {
channels = await getCurrentDiscordVoiceChannels();
} catch (error) {
console.error("Failed to load Discord voice channels", error);
return (
<LeaderboardShell
active="vc"
title="อันดับเวลาในห้องเสียง"
description="รวมเวลาจากห้องเสียงและห้องเวทีที่ยังอยู่ใน Discord ตอนนี้ หรือเลือกดูเฉพาะห้องได้"
>
<LeaderboardError source="discord" />
</LeaderboardShell>
);
}
const requestedChannelId = typeof query.channel === "string"
? query.channel
: undefined;
const selectedChannelId = channels.some(
(channel) => channel.id === requestedChannelId,
)
? requestedChannelId
: undefined;
const invalidChannel = query.channel !== undefined && !selectedChannelId;
let data;
try {
data = await getVoiceLeaderboardPage(
channels.map((channel) => channel.id),
requestedPage,
selectedChannelId,
);
} catch (error) {
console.error("Failed to load voice leaderboard", error);
return (
<LeaderboardShell
active="vc"
title="อันดับเวลาในห้องเสียง"
description="รวมเวลาจากห้องเสียงและห้องเวทีที่ยังอยู่ใน Discord ตอนนี้ หรือเลือกดูเฉพาะห้องได้"
>
<ChannelSelect
channels={channels}
selectedChannelId={selectedChannelId}
/>
<LeaderboardError source="database" />
</LeaderboardShell>
);
}
if (
invalidChannel
|| (query.page !== undefined && query.page !== String(data.page))
) {
redirect(voiceLeaderboardUrl(data.page, selectedChannelId));
}
return (
<LeaderboardShell
active="vc"
title="อันดับเวลาในห้องเสียง"
description="รวมเวลาจากห้องเสียงและห้องเวทีที่ยังอยู่ใน Discord ตอนนี้ หรือเลือกดูเฉพาะห้องได้"
>
<RealtimeRefresh
topic="leaderboards"
leaderboard="vc"
channelId={selectedChannelId}
/>
<ChannelSelect
channels={channels}
selectedChannelId={selectedChannelId}
/>
<LeaderboardBoard
kind="vc"
data={data}
channelId={selectedChannelId}
/>
</LeaderboardShell>
);
}
+62
View File
@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { LeaderboardBoard } from "@/components/leaderboard/leaderboard-board";
import { LeaderboardError } from "@/components/leaderboard/leaderboard-error";
import { LeaderboardShell } from "@/components/leaderboard/leaderboard-shell";
import { RealtimeRefresh } from "@/components/realtime-refresh";
import { getXpLeaderboardPage } from "@/lib/leaderboard/database";
import { normalizeRequestedPage } from "@/lib/leaderboard/ranking";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "อันดับ XP",
description: "ดูอันดับ XP รวมและเลเวลของสมาชิกใน Discord ของ Erika!",
alternates: {
canonical: "/leaderboard/xp",
},
};
type XpLeaderboardPageProps = {
searchParams: Promise<{
page?: string | string[];
}>;
};
export default async function XpLeaderboardPage({
searchParams,
}: XpLeaderboardPageProps) {
const query = await searchParams;
const requestedPage = normalizeRequestedPage(query.page);
let data;
try {
data = await getXpLeaderboardPage(requestedPage);
} catch (error) {
console.error("Failed to load XP leaderboard", error);
return (
<LeaderboardShell
active="xp"
title="อันดับ XP"
description="จัดอันดับจาก XP รวมของสมาชิกที่มีเลเวล 1 ขึ้นไป อันดับที่มี XP เท่ากันจะแชร์อันดับเดียวกัน"
>
<LeaderboardError source="database" />
</LeaderboardShell>
);
}
if (query.page !== undefined && query.page !== String(data.page)) {
redirect(data.page > 1 ? `/leaderboard/xp?page=${data.page}` : "/leaderboard/xp");
}
return (
<LeaderboardShell
active="xp"
title="อันดับ XP"
description="จัดอันดับจาก XP รวมของสมาชิกที่มีเลเวล 1 ขึ้นไป อันดับที่มี XP เท่ากันจะแชร์อันดับเดียวกัน"
>
<RealtimeRefresh topic="leaderboards" leaderboard="xp" />
<LeaderboardBoard kind="xp" data={data} />
</LeaderboardShell>
);
}
+12
View File
@@ -22,5 +22,17 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "yearly",
priority: 0.5,
},
{
url: `${baseUrl}/leaderboard/xp`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.8,
},
{
url: `${baseUrl}/leaderboard/vc`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.8,
},
];
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import {
getAdminDiscordIds,
getSessionDiscordId,