chore(file-path): correct /profile and /leaderboard file path

This commit is contained in:
2026-08-10 17:28:43 +07:00 Unverified
parent d038c95d35
commit 1ac594293a
16 changed files with 364 additions and 379 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ export default async function FormLayout({
return (
<div className="relative flex min-h-screen w-full flex-1 flex-col overflow-x-hidden bg-background text-foreground">
<header className="relative z-10 flex shrink-0 items-center justify-between border-b border-border bg-background/90 px-6 py-3 backdrop-blur">
<Link href="/form">
<Link href="/">
<span className="font-semibold text-sm">
Form
</span>
+151
View File
@@ -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 (
<Field className="max-w-md">
<FieldLabel htmlFor="voice-channel">ห้องเสียง</FieldLabel>
<Select
value={selectedChannelId ?? ALL_CHANNELS}
onValueChange={(value) => {
const params = new URLSearchParams();
if (value !== ALL_CHANNELS) params.set("channel", value);
if (searchQuery) params.set("q", searchQuery);
const query = params.toString();
router.push(query ? `/leaderboard/vc?${query}` : "/leaderboard/vc");
}}
>
<SelectTrigger id="voice-channel" className="w-full bg-card!">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={ALL_CHANNELS}>ทุกห้องเสียงปัจจุบัน</SelectItem>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.type === 13 ? "เวที: " : ""}
{channel.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
);
}
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 (
<form
className="flex w-full flex-col gap-2 sm:max-w-md"
onSubmit={(event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
navigate(String(formData.get("q") ?? ""));
}}
>
<Field>
<FieldLabel htmlFor="leaderboard-search">ค้นหาสมาชิก</FieldLabel>
<div className="flex gap-2">
<Input
id="leaderboard-search"
name="q"
key={query}
defaultValue={query}
placeholder="ชื่อ, username หรือ Discord ID"
autoComplete="off"
className="bg-card!"
/>
{query && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => navigate("")}
>
<X data-icon="inline-start" />
<span className="sr-only">ล้างการค้นหา</span>
</Button>
)}
<Button type="submit" variant="secondary" size="icon">
<Search data-icon="inline-start" />
<span className="sr-only">ค้นหา</span>
</Button>
</div>
</Field>
</form>
);
}
export function LeaderboardTabs({
active,
searchQuery,
}: {
active: "xp" | "vc";
searchQuery: string;
}) {
const router = useRouter();
return (
<Tabs
value={active}
onValueChange={(value) => {
const params = new URLSearchParams();
if (searchQuery) params.set("q", searchQuery);
const query = params.toString();
router.push(query ? `/leaderboard/${value}?${query}` : `/leaderboard/${value}`);
}}
>
<TabsList className="w-full sm:w-fit" aria-label="เลือกประเภทลีดเดอร์บอร์ด">
<TabsTrigger value="xp">อันดับ XP</TabsTrigger>
<TabsTrigger value="vc">เวลาในห้องเสียง</TabsTrigger>
</TabsList>
</Tabs>
);
}
@@ -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 (
<Pagination aria-label="หน้าอันดับ">
<PaginationContent>
{page > 1 && (
<PaginationItem>
<PaginationPrevious
href={pageHref(path, page - 1, channelId, searchQuery)}
text="ก่อนหน้า"
aria-label="ไปหน้าก่อนหน้า"
/>
</PaginationItem>
)}
{pages.map((value, index) => (
<Fragment key={value}>
{index > 0 && value - pages[index - 1] > 1 && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
<PaginationItem>
<PaginationLink
href={pageHref(path, value, channelId, searchQuery)}
isActive={value === page}
aria-label={`ไปหน้า ${value}`}
>
{value}
</PaginationLink>
</PaginationItem>
</Fragment>
))}
{page < totalPages && (
<PaginationItem>
<PaginationNext
href={pageHref(path, page + 1, channelId, searchQuery)}
text="ถัดไป"
aria-label="ไปหน้าถัดไป"
/>
</PaginationItem>
)}
</PaginationContent>
</Pagination>
);
}
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 (
<Card role="alert">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon />
{source === "database"
? "โหลดข้อมูลอันดับไม่สำเร็จ"
: "โหลดรายชื่อห้อง Discord ไม่สำเร็จ"}
</CardTitle>
<CardDescription>
{source === "database"
? "ฐานข้อมูลอันดับยังไม่พร้อมใช้งาน ลองเปิดหน้านี้ใหม่อีกครั้ง"
: "ยังเลือกขอบเขตห้องเสียงไม่ได้ ลองเปิดหน้านี้ใหม่เมื่อ Discord พร้อมใช้งาน"}
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
หน้านี้ไม่มีการดึงข้อมูลซ้ำอัตโนมัติระหว่างที่ระบบขัดข้อง
</CardContent>
</Card>
);
}
type LeaderboardShellProps = {
active: "xp" | "vc";
title: string;
description: string;
searchQuery: string;
children: ReactNode;
};
export function LeaderboardShell({
active,
title,
description,
searchQuery,
children,
}: LeaderboardShellProps) {
return (
<div className="relative mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 sm:px-6 lg:py-10">
<nav aria-label="ทางลัด" className="flex items-center justify-between gap-3">
<Button variant="ghost" asChild>
<Link href="/">
<ArrowLeft data-icon="inline-start" />
กลับหน้าหลัก
</Link>
</Button>
<ThemeToggle />
</nav>
<Card>
<CardHeader>
<CardTitle>
<h1 className="text-2xl font-semibold sm:text-3xl">{title}</h1>
</CardTitle>
<CardDescription className="max-w-2xl">{description}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<LeaderboardTabs active={active} searchQuery={searchQuery} />
<LeaderboardSearch query={searchQuery} />
</CardContent>
</Card>
{children}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
export default function LeaderboardLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<main className="relative min-h-screen overflow-hidden bg-background text-foreground">
{children}
</main>
);
}
+2 -4
View File
@@ -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";
+1 -3
View File
@@ -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";
+27
View File
@@ -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 (
<main className="relative min-h-screen overflow-hidden bg-background text-foreground">
<div className="relative mx-auto flex w-full max-w-5xl flex-col gap-6 px-4 py-6 sm:px-6 lg:py-10">
<nav aria-label="ทางลัด" className="flex items-center justify-between gap-3">
<Button variant="ghost" asChild>
<Link href="/">
<ArrowLeft data-icon="inline-start" />
กลับหน้าหลัก
</Link>
</Button>
<ThemeToggle />
</nav>
{children}
</div>
</main>
);
}
+3 -22
View File
@@ -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 (
<main className="relative min-h-screen overflow-hidden bg-background text-foreground">
<div className="relative mx-auto flex w-full max-w-5xl flex-col gap-6 px-4 py-6 sm:px-6 lg:py-10">
<nav aria-label="ทางลัด" className="flex items-center justify-between gap-3">
<Button variant="ghost" asChild>
<Link href="/">
<ArrowLeft data-icon="inline-start" />
กลับหน้าหลัก
</Link>
</Button>
<ThemeToggle />
</nav>
{content}
</div>
</main>
);
return content;
}
-58
View File
@@ -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 (
<Field className="max-w-md">
<FieldLabel htmlFor="voice-channel">ห้องเสียง</FieldLabel>
<Select
value={selectedChannelId ?? ALL_CHANNELS}
onValueChange={(value) => {
const params = new URLSearchParams();
if (value !== ALL_CHANNELS) params.set("channel", value);
if (searchQuery) params.set("q", searchQuery);
const query = params.toString();
router.push(query ? `/leaderboard/vc?${query}` : "/leaderboard/vc");
}}
>
<SelectTrigger id="voice-channel" className="w-full bg-card!">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={ALL_CHANNELS}>ทุกห้องเสียงปัจจุบัน</SelectItem>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.type === 13 ? "เวที: " : ""}
{channel.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
);
}
@@ -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 (
<Card role="alert">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon />
{source === "database"
? "โหลดข้อมูลอันดับไม่สำเร็จ"
: "โหลดรายชื่อห้อง Discord ไม่สำเร็จ"}
</CardTitle>
<CardDescription>
{source === "database"
? "ฐานข้อมูลอันดับยังไม่พร้อมใช้งาน ลองเปิดหน้านี้ใหม่อีกครั้ง"
: "ยังเลือกขอบเขตห้องเสียงไม่ได้ ลองเปิดหน้านี้ใหม่เมื่อ Discord พร้อมใช้งาน"}
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
หน้านี้ไม่มีการดึงข้อมูลซ้ำอัตโนมัติระหว่างที่ระบบขัดข้อง
</CardContent>
</Card>
);
}
@@ -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 (
<Pagination aria-label="หน้าอันดับ">
<PaginationContent>
{page > 1 && (
<PaginationItem>
<PaginationPrevious
href={pageHref(path, page - 1, channelId, searchQuery)}
text="ก่อนหน้า"
aria-label="ไปหน้าก่อนหน้า"
/>
</PaginationItem>
)}
{pages.map((value, index) => (
<Fragment key={value}>
{index > 0 && value - pages[index - 1] > 1 && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
<PaginationItem>
<PaginationLink
href={pageHref(path, value, channelId, searchQuery)}
isActive={value === page}
aria-label={`ไปหน้า ${value}`}
>
{value}
</PaginationLink>
</PaginationItem>
</Fragment>
))}
{page < totalPages && (
<PaginationItem>
<PaginationNext
href={pageHref(path, page + 1, channelId, searchQuery)}
text="ถัดไป"
aria-label="ไปหน้าถัดไป"
/>
</PaginationItem>
)}
</PaginationContent>
</Pagination>
);
}
@@ -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 (
<form
className="flex w-full flex-col gap-2 sm:max-w-md"
onSubmit={(event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
navigate(String(formData.get("q") ?? ""));
}}
>
<Field>
<FieldLabel htmlFor="leaderboard-search">ค้นหาสมาชิก</FieldLabel>
<div className="flex gap-2">
<Input
id="leaderboard-search"
name="q"
key={query}
defaultValue={query}
placeholder="ชื่อ, username หรือ Discord ID"
autoComplete="off"
className="bg-card!"
/>
{query && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => navigate("")}
>
<X data-icon="inline-start" />
<span className="sr-only">ล้างการค้นหา</span>
</Button>
)}
<Button type="submit" variant="secondary" size="icon">
<Search data-icon="inline-start" />
<span className="sr-only">ค้นหา</span>
</Button>
</div>
</Field>
</form>
);
}
@@ -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 (
<main className="relative min-h-screen overflow-hidden bg-background text-foreground">
<SpaceBackground />
<div className="relative mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 sm:px-6 lg:py-10">
<nav aria-label="ทางลัด" className="flex items-center justify-between gap-3">
<Button variant="ghost" asChild>
<Link href="/">
<ArrowLeft data-icon="inline-start" />
กลับหน้าหลัก
</Link>
</Button>
<ThemeToggle />
</nav>
<Card>
<CardHeader>
<CardTitle>
<h1 className="text-2xl font-semibold sm:text-3xl">{title}</h1>
</CardTitle>
<CardDescription className="max-w-2xl">{description}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<LeaderboardTabs active={active} searchQuery={searchQuery} />
<LeaderboardSearch query={searchQuery} />
</CardContent>
</Card>
{children}
</div>
</main>
);
}
@@ -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 (
<Tabs
value={active}
onValueChange={(value) => {
const params = new URLSearchParams();
if (searchQuery) params.set("q", searchQuery);
const query = params.toString();
router.push(query ? `/leaderboard/${value}?${query}` : `/leaderboard/${value}`);
}}
>
<TabsList className="w-full sm:w-fit" aria-label="เลือกประเภทลีดเดอร์บอร์ด">
<TabsTrigger value="xp">อันดับ XP</TabsTrigger>
<TabsTrigger value="vc">เวลาในห้องเสียง</TabsTrigger>
</TabsList>
</Tabs>
);
}