262 lines
9.8 KiB
TypeScript
262 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|