chore(file-path): correct /profile and /leaderboard file path
This commit is contained in:
+1
-1
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user