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