diff --git a/app/form/layout.tsx b/app/form/layout.tsx
index 37a4030..4356ecb 100644
--- a/app/form/layout.tsx
+++ b/app/form/layout.tsx
@@ -49,7 +49,7 @@ export default async function FormLayout({
return (
-
+
Form
diff --git a/app/leaderboard/client.tsx b/app/leaderboard/client.tsx
new file mode 100644
index 0000000..9ec9109
--- /dev/null
+++ b/app/leaderboard/client.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { Search, X } from "lucide-react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Field, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import type { DiscordVoiceChannel } from "@/lib/leaderboard/discord";
+
+const ALL_CHANNELS = "all";
+
+export function ChannelSelect({
+ channels,
+ selectedChannelId,
+ searchQuery,
+}: {
+ channels: DiscordVoiceChannel[];
+ selectedChannelId?: string;
+ searchQuery?: string;
+}) {
+ const router = useRouter();
+
+ return (
+
+ ห้องเสียง
+
+
+ );
+}
+
+export function LeaderboardSearch({ query }: { query: string }) {
+ const pathname = usePathname();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ function navigate(nextValue: string) {
+ const params = new URLSearchParams(searchParams);
+ const trimmed = nextValue.trim();
+
+ params.delete("page");
+ if (trimmed) {
+ params.set("q", trimmed);
+ } else {
+ params.delete("q");
+ }
+
+ const nextQuery = params.toString();
+ router.push(nextQuery ? `${pathname}?${nextQuery}` : pathname);
+ }
+
+ return (
+
+ );
+}
+
+export function LeaderboardTabs({
+ active,
+ searchQuery,
+}: {
+ active: "xp" | "vc";
+ searchQuery: string;
+}) {
+ const router = useRouter();
+
+ return (
+ {
+ const params = new URLSearchParams();
+ if (searchQuery) params.set("q", searchQuery);
+ const query = params.toString();
+ router.push(query ? `/leaderboard/${value}?${query}` : `/leaderboard/${value}`);
+ }}
+ >
+
+ อันดับ XP
+ เวลาในห้องเสียง
+
+
+ );
+}
diff --git a/components/leaderboard/leaderboard-board.tsx b/app/leaderboard/content.tsx
similarity index 53%
rename from components/leaderboard/leaderboard-board.tsx
rename to app/leaderboard/content.tsx
index 0df444b..bc312d5 100644
--- a/components/leaderboard/leaderboard-board.tsx
+++ b/app/leaderboard/content.tsx
@@ -1,6 +1,10 @@
-import { Trophy } from "lucide-react";
-import { LeaderboardPagination } from "@/components/leaderboard/leaderboard-pagination";
+import { Fragment, type ReactNode } from "react";
+import Link from "next/link";
+import { ArrowLeft, DatabaseZap, RadioTower, Trophy } from "lucide-react";
+import { LeaderboardSearch, LeaderboardTabs } from "./client";
+import { ThemeToggle } from "@/components/theme-toggle";
import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -9,6 +13,15 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
+import {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "@/components/ui/pagination";
import {
Table,
TableBody,
@@ -116,6 +129,89 @@ function Podium({ data }: LeaderboardBoardProps) {
);
}
+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,
+ searchQuery?: string,
+) {
+ const params = new URLSearchParams();
+ if (channelId) params.set("channel", channelId);
+ if (searchQuery) params.set("q", searchQuery);
+ if (page > 1) params.set("page", String(page));
+ const query = params.toString();
+ return query ? `${path}?${query}` : path;
+}
+
+function LeaderboardPagination({
+ path,
+ page,
+ totalPages,
+ channelId,
+ searchQuery,
+}: {
+ path: string;
+ page: number;
+ totalPages: number;
+ channelId?: string;
+ searchQuery?: 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 && (
+
+
+
+ )}
+
+
+ );
+}
+
export function LeaderboardBoard(props: LeaderboardBoardProps) {
const { kind, data, channelId, searchQuery } = props;
const firstRow = data.totalRows === 0
@@ -192,3 +288,73 @@ export function LeaderboardBoard(props: LeaderboardBoardProps) {
>
);
}
+
+export function LeaderboardError({ source }: { source: "database" | "discord" }) {
+ const Icon = source === "database" ? DatabaseZap : RadioTower;
+
+ return (
+
+
+
+
+ {source === "database"
+ ? "โหลดข้อมูลอันดับไม่สำเร็จ"
+ : "โหลดรายชื่อห้อง Discord ไม่สำเร็จ"}
+
+
+ {source === "database"
+ ? "ฐานข้อมูลอันดับยังไม่พร้อมใช้งาน ลองเปิดหน้านี้ใหม่อีกครั้ง"
+ : "ยังเลือกขอบเขตห้องเสียงไม่ได้ ลองเปิดหน้านี้ใหม่เมื่อ Discord พร้อมใช้งาน"}
+
+
+
+ หน้านี้ไม่มีการดึงข้อมูลซ้ำอัตโนมัติระหว่างที่ระบบขัดข้อง
+
+
+ );
+}
+
+type LeaderboardShellProps = {
+ active: "xp" | "vc";
+ title: string;
+ description: string;
+ searchQuery: string;
+ children: ReactNode;
+};
+
+export function LeaderboardShell({
+ active,
+ title,
+ description,
+ searchQuery,
+ children,
+}: LeaderboardShellProps) {
+ return (
+
+
+
+
+
+
+ {title}
+
+ {description}
+
+
+
+
+
+
+
+ {children}
+
+ );
+}
diff --git a/app/leaderboard/layout.tsx b/app/leaderboard/layout.tsx
new file mode 100644
index 0000000..83754fb
--- /dev/null
+++ b/app/leaderboard/layout.tsx
@@ -0,0 +1,11 @@
+export default function LeaderboardLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/leaderboard/vc/page.tsx b/app/leaderboard/vc/page.tsx
index 69cc602..9ef5f02 100644
--- a/app/leaderboard/vc/page.tsx
+++ b/app/leaderboard/vc/page.tsx
@@ -1,9 +1,7 @@
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 { ChannelSelect } from "../client";
+import { LeaderboardBoard, LeaderboardError, LeaderboardShell } from "../content";
import { RealtimeRefresh } from "@/components/realtime-refresh";
import { getVoiceLeaderboardPage } from "@/lib/leaderboard/database";
import { getCurrentDiscordVoiceChannels } from "@/lib/leaderboard/discord";
diff --git a/app/leaderboard/xp/page.tsx b/app/leaderboard/xp/page.tsx
index ea95e8c..7300bef 100644
--- a/app/leaderboard/xp/page.tsx
+++ b/app/leaderboard/xp/page.tsx
@@ -1,8 +1,6 @@
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 { LeaderboardBoard, LeaderboardError, LeaderboardShell } from "../content";
import { RealtimeRefresh } from "@/components/realtime-refresh";
import { getXpLeaderboardPage } from "@/lib/leaderboard/database";
import { normalizeLeaderboardSearch, normalizeRequestedPage } from "@/lib/leaderboard/ranking";
diff --git a/components/profile/profile-login-card.tsx b/app/profile/client.tsx
similarity index 100%
rename from components/profile/profile-login-card.tsx
rename to app/profile/client.tsx
diff --git a/components/profile/profile-passport.tsx b/app/profile/content.tsx
similarity index 100%
rename from components/profile/profile-passport.tsx
rename to app/profile/content.tsx
diff --git a/app/profile/layout.tsx b/app/profile/layout.tsx
new file mode 100644
index 0000000..f7eeba4
--- /dev/null
+++ b/app/profile/layout.tsx
@@ -0,0 +1,27 @@
+import Link from "next/link";
+import { ArrowLeft } from "lucide-react";
+import { ThemeToggle } from "@/components/theme-toggle";
+import { Button } from "@/components/ui/button";
+
+export default function ProfileLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/app/profile/page.tsx b/app/profile/page.tsx
index a82572d..414f3f4 100644
--- a/app/profile/page.tsx
+++ b/app/profile/page.tsx
@@ -1,15 +1,11 @@
import type { Metadata } from "next";
-import Link from "next/link";
import { getServerSession } from "next-auth";
-import { ArrowLeft } from "lucide-react";
+import { ProfileLoginCard } from "./client";
+import { ProfilePassport } from "./content";
import { authOptions } from "@/lib/auth/auth-options";
import { getSessionDiscordId } from "@/lib/auth/auth";
import { loadGuildProfile } from "@/lib/profile/guild-profile";
-import { ProfileLoginCard } from "@/components/profile/profile-login-card";
-import { ProfilePassport } from "@/components/profile/profile-passport";
import { RealtimeRefresh } from "@/components/realtime-refresh";
-import { ThemeToggle } from "@/components/theme-toggle";
-import { Button } from "@/components/ui/button";
export const dynamic = "force-dynamic";
@@ -44,20 +40,5 @@ export default async function ProfilePage() {
);
}
- return (
-
-
-
- {content}
-
-
- );
+ return content;
}
diff --git a/components/leaderboard/channel-select.tsx b/components/leaderboard/channel-select.tsx
deleted file mode 100644
index 4506da4..0000000
--- a/components/leaderboard/channel-select.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-"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,
- searchQuery,
-}: {
- channels: DiscordVoiceChannel[];
- selectedChannelId?: string;
- searchQuery?: string;
-}) {
- const router = useRouter();
-
- return (
-
- ห้องเสียง
-
-
- );
-}
diff --git a/components/leaderboard/leaderboard-error.tsx b/components/leaderboard/leaderboard-error.tsx
deleted file mode 100644
index 031149b..0000000
--- a/components/leaderboard/leaderboard-error.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-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
deleted file mode 100644
index b7a5164..0000000
--- a/components/leaderboard/leaderboard-pagination.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-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,
- searchQuery?: string,
-) {
- const params = new URLSearchParams();
- if (channelId) params.set("channel", channelId);
- if (searchQuery) params.set("q", searchQuery);
- if (page > 1) params.set("page", String(page));
- const query = params.toString();
- return query ? `${path}?${query}` : path;
-}
-
-export function LeaderboardPagination({
- path,
- page,
- totalPages,
- channelId,
- searchQuery,
-}: {
- path: string;
- page: number;
- totalPages: number;
- channelId?: string;
- searchQuery?: 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-search.tsx b/components/leaderboard/leaderboard-search.tsx
deleted file mode 100644
index 3ff9da3..0000000
--- a/components/leaderboard/leaderboard-search.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-"use client";
-
-import { Search, X } from "lucide-react";
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import { Field, FieldLabel } from "@/components/ui/field";
-import { Input } from "@/components/ui/input";
-
-export function LeaderboardSearch({ query }: { query: string }) {
- const pathname = usePathname();
- const router = useRouter();
- const searchParams = useSearchParams();
-
- function navigate(nextValue: string) {
- const params = new URLSearchParams(searchParams);
- const trimmed = nextValue.trim();
-
- params.delete("page");
- if (trimmed) {
- params.set("q", trimmed);
- } else {
- params.delete("q");
- }
-
- const nextQuery = params.toString();
- router.push(nextQuery ? `${pathname}?${nextQuery}` : pathname);
- }
-
- return (
-
- );
-}
diff --git a/components/leaderboard/leaderboard-shell.tsx b/components/leaderboard/leaderboard-shell.tsx
deleted file mode 100644
index c715da6..0000000
--- a/components/leaderboard/leaderboard-shell.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import type { ReactNode } from "react";
-import Link from "next/link";
-import { ArrowLeft } from "lucide-react";
-import { LeaderboardSearch } from "@/components/leaderboard/leaderboard-search";
-import { LeaderboardTabs } from "@/components/leaderboard/leaderboard-tabs";
-import { SpaceBackground } from "@/components/space-background";
-import { ThemeToggle } from "@/components/theme-toggle";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-
-type LeaderboardShellProps = {
- active: "xp" | "vc";
- title: string;
- description: string;
- searchQuery: string;
- children: ReactNode;
-};
-
-export function LeaderboardShell({
- active,
- title,
- description,
- searchQuery,
- children,
-}: LeaderboardShellProps) {
- return (
-
-
-
-
-
-
-
-
- {title}
-
- {description}
-
-
-
-
-
-
-
- {children}
-
-
- );
-}
diff --git a/components/leaderboard/leaderboard-tabs.tsx b/components/leaderboard/leaderboard-tabs.tsx
deleted file mode 100644
index 381532c..0000000
--- a/components/leaderboard/leaderboard-tabs.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-"use client";
-
-import { useRouter } from "next/navigation";
-import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
-
-export function LeaderboardTabs({
- active,
- searchQuery,
-}: {
- active: "xp" | "vc";
- searchQuery: string;
-}) {
- const router = useRouter();
-
- return (
- {
- const params = new URLSearchParams();
- if (searchQuery) params.set("q", searchQuery);
- const query = params.toString();
- router.push(query ? `/leaderboard/${value}?${query}` : `/leaderboard/${value}`);
- }}
- >
-
- อันดับ XP
- เวลาในห้องเสียง
-
-
- );
-}