Files
erika/app/(ui)/profile/content.tsx
T
2026-08-27 14:11:20 +00:00

261 lines
9.7 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, DiscordServerTag } from "@/components/avatar";
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>
);
}