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
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { signIn } from "next-auth/react";
import { DiscordIcon } from "@/lib/icons";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function ProfileLoginCard() {
return (
<Card className="mx-auto w-full max-w-md">
<CardHeader>
<CardTitle>ดูโปรไฟล์ของคุณ</CardTitle>
<CardDescription>
เข้าสู่ระบบด้วย Discord เพื่อดูเลเวล XP เวลาในห้องเสียง และอันดับของคุณ
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
ระบบใช้บัญชี Discord เพื่อจับคู่คุณกับข้อมูลกิจกรรมในเซิร์ฟเวอร์ Erika!
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={() => signIn("discord", { callbackUrl: "/profile" })}
>
<DiscordIcon data-icon="inline-start" />
ดำเนินการต่อด้วย Discord
</Button>
</CardFooter>
</Card>
);
}
+261
View File
@@ -0,0 +1,261 @@
import Image from "next/image";
import { Award, Clock3, DatabaseZap, RadioTower } from "lucide-react";
import type { DiscordMemberProfile, DiscordRole } from "@/lib/discord/discord";
import type { LevelProgress } from "@/lib/leaderboard/level-progress";
import { formatInteger, formatVoiceDuration } from "@/lib/leaderboard/ranking";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { DiscordAvatar } from "@/components/discord-avatar";
import { DiscordServerTag } from "@/components/discord-server-tag";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import {
discordColorToHex,
} from "@/lib/discord/profile-appearance";
type RankedXp = LevelProgress & { rank: number | null };
type RankedVoice = { totalSeconds: string; rank: number | null };
type ProfilePassportProps = {
userName: string;
userImage: string;
profile: DiscordMemberProfile | null;
profileUnavailable: boolean;
xp: RankedXp | null;
voice: RankedVoice | null;
};
const DEFAULT_ROLE_COLOR = "#99aab5";
function toHex(value: number) {
return discordColorToHex(value);
}
function initials(value: string) {
return value.trim().slice(0, 2).toLocaleUpperCase("th-TH") || "?";
}
function rankLabel(rank: number | null) {
return rank ? `#${formatInteger(String(rank))}` : "ยังไม่ติดอันดับ";
}
function RoleBadge({ role }: { role: DiscordRole }) {
const color = role.color === 0 ? DEFAULT_ROLE_COLOR : toHex(role.color);
return (
<Badge variant="outline">
<span
aria-hidden
className="size-2 rounded-full"
style={{ backgroundColor: color }}
/>
{role.name}
</Badge>
);
}
function UnavailableAlert({ kind }: { kind: "xp" | "voice" }) {
const Icon = kind === "xp" ? DatabaseZap : RadioTower;
return (
<Alert>
<Icon />
<AlertTitle>
{kind === "xp" ? "โหลดข้อมูล XP ไม่สำเร็จ" : "โหลดเวลาในห้องเสียงไม่สำเร็จ"}
</AlertTitle>
<AlertDescription>
ลองเปิดหน้านี้ใหม่อีกครั้งเมื่อระบบพร้อมใช้งาน
</AlertDescription>
</Alert>
);
}
function XpCard({ xp }: { xp: RankedXp | null }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex flex-wrap items-center justify-between gap-3">
<span className="flex items-center gap-2">
<Award />
ความก้าวหน้า XP
</span>
{xp && <Badge variant="secondary">อันดับ {rankLabel(xp.rank)}</Badge>}
</CardTitle>
<CardDescription>เลเวลและ XP สะสมจากกิจกรรมใน Discord</CardDescription>
</CardHeader>
<CardContent>
{xp ? (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">เลเวลปัจจุบัน</p>
<p className="text-4xl font-semibold tabular-nums">{xp.level}</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">XP สะสม</p>
<p className="text-xl font-semibold tabular-nums">
{formatInteger(xp.totalXp)} XP
</p>
</div>
</div>
{xp.isMaxLevel ? (
<div className="flex flex-col gap-2">
<Badge className="w-fit">เลเวลสูงสุด</Badge>
<p className="text-sm text-muted-foreground">
คุณถึงเลเวลสูงสุดของระบบแล้ว
</p>
</div>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="tabular-nums">
{formatInteger(xp.xpIntoLevel)} / {formatInteger(xp.xpRequired)} XP
</span>
<span className="text-muted-foreground tabular-nums">
อีก {formatInteger(xp.xpToNextLevel)} XP
</span>
</div>
<Progress
aria-label={`ความคืบหน้าสู่เลเวล ${xp.level + 1}`}
value={xp.percentage}
/>
</div>
)}
</div>
) : (
<UnavailableAlert kind="xp" />
)}
</CardContent>
<CardFooter className="text-sm text-muted-foreground">
อันดับคำนวณจาก XP สะสมของสมาชิกเลเวล 1 ขึ้นไป
</CardFooter>
</Card>
);
}
function VoiceCard({ voice }: { voice: RankedVoice | null }) {
const duration = voice?.totalSeconds === "0"
? "0 นาที"
: voice
? formatVoiceDuration(voice.totalSeconds)
: null;
return (
<Card>
<CardHeader>
<CardTitle className="flex flex-wrap items-center justify-between gap-3">
<span className="flex items-center gap-2">
<Clock3 />
เวลาในห้องเสียง
</span>
{voice && <Badge variant="secondary">อันดับ {rankLabel(voice.rank)}</Badge>}
</CardTitle>
<CardDescription>
เวลารวมจากห้องเสียงและห้องเวทีที่ยังอยู่ในเซิร์ฟเวอร์
</CardDescription>
</CardHeader>
<CardContent>
{voice ? (
<p className="text-3xl font-semibold tabular-nums">{duration}</p>
) : (
<UnavailableAlert kind="voice" />
)}
</CardContent>
<CardFooter className="text-sm text-muted-foreground">
ห้องที่ถูกลบหรือไม่อยู่ในเซิร์ฟเวอร์แล้วจะไม่รวมในยอดนี้
</CardFooter>
</Card>
);
}
export function ProfilePassport({
userName,
userImage,
profile,
profileUnavailable,
xp,
voice,
}: ProfilePassportProps) {
const displayName = profile?.globalName || userName || profile?.username || "สมาชิก Erika!";
const username = profile?.username || userName;
const avatarUrl = profile?.avatarUrl || userImage;
return (
<div className="flex flex-col gap-5">
<Card className="overflow-hidden py-0">
<div className="relative h-36 bg-muted sm:h-44">
{profile?.bannerUrl && (
<Image
src={profile.bannerUrl}
alt=""
fill
priority
sizes="(max-width: 768px) 100vw, 768px"
className="object-cover"
unoptimized
/>
)}
</div>
<CardHeader className="relative -mt-12 items-start pb-5 sm:-mt-14 sm:flex sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<DiscordAvatar
avatarUrl={avatarUrl || null}
decorationUrl={profile?.avatarDecorationUrl}
alt={displayName}
fallback={initials(displayName)}
className="size-24 rounded-full border-4 border-card sm:size-28"
/>
<div className="flex min-w-0 flex-col gap-1 pb-1">
<div className="flex min-w-0 items-center gap-2">
<CardTitle className="truncate text-2xl sm:text-3xl">{displayName}</CardTitle>
{profile && <DiscordServerTag primaryGuild={profile.primaryGuild} />}
</div>
<CardDescription className="truncate">@{username}</CardDescription>
</div>
</div>
{xp && <Badge variant="outline">เลเวล {xp.level}</Badge>}
</CardHeader>
<CardContent className="flex flex-col gap-4 pb-6">
{profileUnavailable && (
<Alert>
<RadioTower />
<AlertTitle>ข้อมูล Discord บางส่วนยังไม่พร้อม</AlertTitle>
<AlertDescription>
กำลังแสดงชื่อและรูปจากบัญชีที่เข้าสู่ระบบ บทบาทและแบนเนอร์อาจยังไม่ครบ
</AlertDescription>
</Alert>
)}
{profile && profile.roles.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
บทบาทในเซิร์ฟเวอร์
</p>
<div className="flex flex-wrap gap-2">
{profile.roles.map((role) => <RoleBadge key={role.id} role={role} />)}
</div>
</div>
)}
</CardContent>
<Separator />
<CardFooter className="py-4 text-sm text-muted-foreground">
โปรไฟล์นี้เห็นได้เฉพาะเมื่อคุณเข้าสู่ระบบด้วยบัญชี Discord ของตัวเอง
</CardFooter>
</Card>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.6fr)]">
<XpCard xp={xp} />
<VoiceCard voice={voice} />
</div>
</div>
);
}
+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;
}