diff --git a/app/actions/submissions.ts b/app/actions/submissions.ts index fb53cc9..b3403cd 100644 --- a/app/actions/submissions.ts +++ b/app/actions/submissions.ts @@ -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 { diff --git a/app/admin/form/[id]/layout.tsx b/app/admin/form/[id]/layout.tsx index 6654053..dc3b98d 100644 --- a/app/admin/form/[id]/layout.tsx +++ b/app/admin/form/[id]/layout.tsx @@ -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"; diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index e4e8e6e..7b6ff19 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -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"; diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index c004cd4..b337159 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -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(); - -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 }; diff --git a/app/form/[id]/page.tsx b/app/form/[id]/page.tsx index b9d9a4e..59aa3f0 100644 --- a/app/form/[id]/page.tsx +++ b/app/form/[id]/page.tsx @@ -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"; diff --git a/app/form/layout.tsx b/app/form/layout.tsx index 7aaf3e9..2aeaaf3 100644 --- a/app/form/layout.tsx +++ b/app/form/layout.tsx @@ -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"; diff --git a/app/form/page.tsx b/app/form/page.tsx index 03d1069..6e09eb1 100644 --- a/app/form/page.tsx +++ b/app/form/page.tsx @@ -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"; diff --git a/app/leaderboard/loading.tsx b/app/leaderboard/loading.tsx new file mode 100644 index 0000000..92f8f7a --- /dev/null +++ b/app/leaderboard/loading.tsx @@ -0,0 +1,18 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function LeaderboardLoading() { + return ( +
+
+ + +
+ + + +
+ +
+
+ ); +} diff --git a/app/leaderboard/route.ts b/app/leaderboard/route.ts new file mode 100644 index 0000000..26d2bde --- /dev/null +++ b/app/leaderboard/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export function GET(request: Request) { + return NextResponse.redirect(new URL("/leaderboard/xp", request.url), 307); +} diff --git a/app/leaderboard/vc/page.tsx b/app/leaderboard/vc/page.tsx new file mode 100644 index 0000000..56579e4 --- /dev/null +++ b/app/leaderboard/vc/page.tsx @@ -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 ( + + + + ); + } + + 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 ( + + + + + ); + } + + if ( + invalidChannel + || (query.page !== undefined && query.page !== String(data.page)) + ) { + redirect(voiceLeaderboardUrl(data.page, selectedChannelId)); + } + + return ( + + + + + + ); +} diff --git a/app/leaderboard/xp/page.tsx b/app/leaderboard/xp/page.tsx new file mode 100644 index 0000000..a56a8a1 --- /dev/null +++ b/app/leaderboard/xp/page.tsx @@ -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 ( + + + + ); + } + + if (query.page !== undefined && query.page !== String(data.page)) { + redirect(data.page > 1 ? `/leaderboard/xp?page=${data.page}` : "/leaderboard/xp"); + } + + return ( + + + + + ); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index ef7cfe8..055f4c2 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -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, + }, ]; } diff --git a/app/sse/[topic]/route.ts b/app/sse/[topic]/route.ts index b4e61ef..4799fe3 100644 --- a/app/sse/[topic]/route.ts +++ b/app/sse/[topic]/route.ts @@ -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, diff --git a/components/LinkCard.tsx b/components/LinkCard.tsx index cf41487..e4a1bd8 100644 --- a/components/LinkCard.tsx +++ b/components/LinkCard.tsx @@ -5,7 +5,7 @@ import type { ComponentType, SVGProps } from "react"; import { Button } from "@/components/ui/button"; import { CardContent } from "@/components/ui/card"; import Link from "next/link"; -import { ArrowLeft, ArrowRight } from "lucide-react"; +import { ArrowLeft, ArrowRight, Trophy } from "lucide-react"; import { LinkConfig, type IconName } from "@/lib/config"; import { getCountForLink, formatCount, FollowerCounts } from "@/lib/followers/utils"; import { RobloxIcon, DiscordIcon, TiktokIcon, YoutubeIcon, FormIcon, DonateIcon } from "@/lib/icons"; @@ -17,6 +17,7 @@ const iconMap: Record>> = { youtube: YoutubeIcon, form: FormIcon, donate: DonateIcon, + leaderboard: Trophy, }; export function LinkCard({ counts, links1, links2 }: { counts: FollowerCounts, links1: LinkConfig[], links2: LinkConfig[] }) { diff --git a/components/admin-shell-server.tsx b/components/admin-shell-server.tsx index 0beff54..d179190 100644 --- a/components/admin-shell-server.tsx +++ b/components/admin-shell-server.tsx @@ -1,5 +1,5 @@ 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"; import { AdminShell } from "@/components/admin-shell"; diff --git a/components/leaderboard/channel-select.tsx b/components/leaderboard/channel-select.tsx new file mode 100644 index 0000000..1ddb85e --- /dev/null +++ b/components/leaderboard/channel-select.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Field, FieldLabel } from "@/components/ui/field"; +import type { DiscordVoiceChannel } from "@/lib/leaderboard/discord"; + +const ALL_CHANNELS = "all"; + +export function ChannelSelect({ + channels, + selectedChannelId, +}: { + channels: DiscordVoiceChannel[]; + selectedChannelId?: string; +}) { + const router = useRouter(); + + return ( + + ห้องเสียง + + + ); +} diff --git a/components/leaderboard/leaderboard-board.tsx b/components/leaderboard/leaderboard-board.tsx new file mode 100644 index 0000000..080b63a --- /dev/null +++ b/components/leaderboard/leaderboard-board.tsx @@ -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; + channelId?: never; + } + | { + kind: "vc"; + data: LeaderboardPage; + 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 ( +
+ + {resolveLeaderboardName({ displayName, username, userId })} + + + {unresolved + ? `Discord ID: ${shortenDiscordId(userId)}` + : displayName?.trim() && username?.trim() + ? `@${username}` + : `Discord ID: ${shortenDiscordId(userId)}`} + +
+ ); +} + +function Podium({ data }: LeaderboardBoardProps) { + if (data.podium.length === 0) return null; + + return ( +
+

สามอันดับนำ

+
+ {data.podium.map((row) => ( + + + + + {resolveLeaderboardName(row)} + + + {row.rank === 1 && } + #{row.rank} + + + + {"level" in row + ? `เลเวล ${row.level}` + : "เวลารวมในห้องเสียง"} + + + + {"totalXp" in row + ? `${formatInteger(row.totalXp)} XP` + : formatVoiceDuration(row.totalSeconds)} + + + ))} +
+
+ ); +} + +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 ( + <> + + + + + + {kind === "xp" ? "อันดับ XP ทั้งหมด" : "อันดับเวลาในห้องเสียง"} + + + {data.totalRows > 0 + ? `แสดง ${firstRow}–${lastRow} จาก ${formatInteger(String(data.totalRows))} คน` + : "ยังไม่มีข้อมูลในขอบเขตนี้"} + + + + + + {kind === "xp" + ? "ตารางอันดับจาก XP รวมของสมาชิกเลเวล 1 ขึ้นไป" + : "ตารางอันดับจากเวลารวมในห้องเสียงและห้องเวทีปัจจุบัน"} + + + + อันดับ + สมาชิก + {kind === "xp" && เลเวล} + + {kind === "xp" ? "XP รวม" : "เวลารวม"} + + + + + {data.rows.map((row) => ( + + + #{row.rank} + + + + + {"level" in row && ( + {row.level} + )} + + {"totalXp" in row + ? formatInteger(row.totalXp) + : formatVoiceDuration(row.totalSeconds)} + + + ))} + +
+
+ + + +
+ + ); +} diff --git a/components/leaderboard/leaderboard-error.tsx b/components/leaderboard/leaderboard-error.tsx new file mode 100644 index 0000000..031149b --- /dev/null +++ b/components/leaderboard/leaderboard-error.tsx @@ -0,0 +1,33 @@ +import { DatabaseZap, RadioTower } from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export function LeaderboardError({ source }: { source: "database" | "discord" }) { + const Icon = source === "database" ? DatabaseZap : RadioTower; + + return ( + + + + + {source === "database" + ? "โหลดข้อมูลอันดับไม่สำเร็จ" + : "โหลดรายชื่อห้อง Discord ไม่สำเร็จ"} + + + {source === "database" + ? "ฐานข้อมูลอันดับยังไม่พร้อมใช้งาน ลองเปิดหน้านี้ใหม่อีกครั้ง" + : "ยังเลือกขอบเขตห้องเสียงไม่ได้ ลองเปิดหน้านี้ใหม่เมื่อ Discord พร้อมใช้งาน"} + + + + หน้านี้ไม่มีการดึงข้อมูลซ้ำอัตโนมัติระหว่างที่ระบบขัดข้อง + + + ); +} diff --git a/components/leaderboard/leaderboard-pagination.tsx b/components/leaderboard/leaderboard-pagination.tsx new file mode 100644 index 0000000..6c199b9 --- /dev/null +++ b/components/leaderboard/leaderboard-pagination.tsx @@ -0,0 +1,85 @@ +import { Fragment } from "react"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + +function visiblePages(page: number, totalPages: number) { + const pages = new Set([1, totalPages, page - 1, page, page + 1]); + return [...pages] + .filter((value) => value >= 1 && value <= totalPages) + .sort((a, b) => a - b); +} + +function pageHref(path: string, 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 ? `${path}?${query}` : path; +} + +export function LeaderboardPagination({ + path, + page, + totalPages, + channelId, +}: { + path: string; + page: number; + totalPages: number; + channelId?: string; +}) { + if (totalPages <= 1) return null; + const pages = visiblePages(page, totalPages); + + return ( + + + {page > 1 && ( + + + + )} + + {pages.map((value, index) => ( + + {index > 0 && value - pages[index - 1] > 1 && ( + + + + )} + + + {value} + + + + ))} + + {page < totalPages && ( + + + + )} + + + ); +} diff --git a/components/leaderboard/leaderboard-shell.tsx b/components/leaderboard/leaderboard-shell.tsx new file mode 100644 index 0000000..847ecf2 --- /dev/null +++ b/components/leaderboard/leaderboard-shell.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { LeaderboardTabs } from "@/components/leaderboard/leaderboard-tabs"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import Background from "@/public/background/space.webp"; + +type LeaderboardShellProps = { + active: "xp" | "vc"; + title: string; + description: string; + children: ReactNode; +}; + +export function LeaderboardShell({ + active, + title, + description, + children, +}: LeaderboardShellProps) { + return ( +
+
+ +
+
+ + + + + +

{title}

+
+ {description} +
+ + + +
+ + {children} +
+
+ ); +} diff --git a/components/leaderboard/leaderboard-tabs.tsx b/components/leaderboard/leaderboard-tabs.tsx new file mode 100644 index 0000000..a679454 --- /dev/null +++ b/components/leaderboard/leaderboard-tabs.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +export function LeaderboardTabs({ active }: { active: "xp" | "vc" }) { + const router = useRouter(); + + return ( + router.push(`/leaderboard/${value}`)} + > + + อันดับ XP + เวลาในห้องเสียง + + + ); +} diff --git a/components/realtime-refresh.tsx b/components/realtime-refresh.tsx index b6e3cce..c905a2a 100644 --- a/components/realtime-refresh.tsx +++ b/components/realtime-refresh.tsx @@ -1,55 +1,72 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { sse, type SseTopic } from "@/lib/sse"; +import { sse } from "@/lib/sse"; +import { + startRealtimeRefresh, + type RealtimeRefreshHandlers, +} from "@/lib/realtime-refresh"; -type RealtimeRefreshProps = { - topic: SseTopic; +type FormRealtimeRefreshProps = { + topic: "forms" | "submissions"; formId?: string; debounceMs?: number; }; -export function RealtimeRefresh({ - topic, - formId, - debounceMs = 150, -}: RealtimeRefreshProps) { +type LeaderboardRealtimeRefreshProps = + | { + topic: "leaderboards"; + leaderboard: "xp"; + channelId?: never; + debounceMs?: number; + } + | { + topic: "leaderboards"; + leaderboard: "vc"; + channelId?: string; + debounceMs?: number; + }; + +type RealtimeRefreshProps = + | FormRealtimeRefreshProps + | LeaderboardRealtimeRefreshProps; + +export function RealtimeRefresh(props: RealtimeRefreshProps) { const router = useRouter(); - const connectedOnce = useRef(false); + const topic = props.topic; + const formId = topic === "leaderboards" ? undefined : props.formId; + const leaderboard = topic === "leaderboards" ? props.leaderboard : undefined; + const channelId = topic === "leaderboards" ? props.channelId : undefined; + const debounceMs = props.debounceMs ?? (topic === "leaderboards" ? 500 : 150); useEffect(() => { - connectedOnce.current = false; - let refreshTimer: ReturnType | undefined; + const filter = topic === "leaderboards" + ? leaderboard === "xp" + ? { kind: "xp" as const } + : { kind: "vc" as const, channelId } + : { kind: "form" as const, formId }; - const refresh = () => { - if (refreshTimer) clearTimeout(refreshTimer); - refreshTimer = setTimeout(() => router.refresh(), debounceMs); - }; + return startRealtimeRefresh({ + filter, + debounceMs, + refresh: () => router.refresh(), + subscribe(handlers: RealtimeRefreshHandlers) { + const options = { onopen: handlers.onOpen }; - const onopen = () => { - if (connectedOnce.current) { - refresh(); - } else { - connectedOnce.current = true; - } - }; - - const handleUpdate = (event: { formId: string }) => { - if (!formId || event.formId === formId) { - refresh(); - } - }; - - const subscription = topic === "forms" - ? sse.forms.sub("update", handleUpdate, { onopen }) - : sse.submissions.sub("update", handleUpdate, { onopen }); - - return () => { - if (refreshTimer) clearTimeout(refreshTimer); - subscription.clean(); - }; - }, [debounceMs, formId, router, topic]); + if (topic === "forms") { + return sse.forms.sub("update", handlers.onForm, options); + } + if (topic === "submissions") { + return sse.submissions.sub("update", handlers.onForm, options); + } + return sse.leaderboards.subMany({ + xp: handlers.onXp, + vc: handlers.onVc, + }, options); + }, + }); + }, [channelId, debounceMs, formId, leaderboard, router, topic]); return null; } diff --git a/components/ui/pagination.tsx b/components/ui/pagination.tsx new file mode 100644 index 0000000..570a929 --- /dev/null +++ b/components/ui/pagination.tsx @@ -0,0 +1,130 @@ +import * as React from "react" +import Link from "next/link" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react" + +function Pagination({ className, ...props }: React.ComponentProps<"nav">) { + return ( +