feat: add realtime Discord leaderboards
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function GET(request: Request) {
|
||||
return NextResponse.redirect(new URL("/leaderboard/xp", request.url), 307);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user