812 lines
31 KiB
TypeScript
812 lines
31 KiB
TypeScript
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { ArrowUpRightIcon, ChevronLeftIcon, DownloadIcon } from "lucide-react";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { buttonVariants } from "@/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardAction,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { SiteHeader } from "@/components/public/site-header";
|
|
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
|
import { TeamSearch } from "@/components/public/team-search";
|
|
import { LiveRefresh } from "@/components/live-refresh";
|
|
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
|
|
import type { PublicGuide } from "@/lib/guides/queries";
|
|
import { artifactTierCount } from "@/lib/guides/artifacts";
|
|
import { navigationIconPath } from "@/lib/navigation";
|
|
import {
|
|
normalizeTargetStat,
|
|
recommendedStatIconName,
|
|
statPriorityColorClass,
|
|
targetStatIconName,
|
|
targetStatOptions,
|
|
} from "@/lib/guides/stats";
|
|
import {
|
|
normalizeTalentPriorities,
|
|
talentRelationSymbol,
|
|
talentTypeLabel,
|
|
} from "@/lib/guides/talents";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
function hasSection(guide: PublicGuide, slug: string): boolean {
|
|
if (slug === "weapon") return guide.weapons.length > 0;
|
|
if (slug === "artifact")
|
|
return (
|
|
guide.artifactGroups.some((group) => group.rows.length > 0) ||
|
|
Boolean(
|
|
guide.artifactProfile &&
|
|
(guide.artifactProfile.sands.length ||
|
|
guide.artifactProfile.goblet.length ||
|
|
guide.artifactProfile.circlet.length),
|
|
)
|
|
);
|
|
if (slug === "constellations") return guide.constellationGroups.some((group) => group.rows.length > 0);
|
|
if (slug === "team") return guide.teams.length > 0;
|
|
return guide.extras.some(
|
|
(extra) => extra.slug === slug && extra.rows.length > 0,
|
|
);
|
|
}
|
|
|
|
export function publicSections(guide: PublicGuide) {
|
|
return guide.sections.filter((section) => hasSection(guide, section.slug));
|
|
}
|
|
|
|
function publicSectionTitle(slug: string, fallback: string): string {
|
|
if (slug === "weapon") return "อาวุธ";
|
|
if (slug === "artifact") return "อาร์ติแฟกต์และค่าสถานะ";
|
|
if (slug === "constellations") return "กลุ่มดาว";
|
|
if (slug === "team") return "คำนวณทีม";
|
|
return fallback;
|
|
}
|
|
|
|
export function StructuredGuideShell({
|
|
guide,
|
|
section,
|
|
}: {
|
|
guide: PublicGuide;
|
|
section?: string;
|
|
}) {
|
|
const sections = publicSections(guide);
|
|
const currentPageTitle = section
|
|
? (sections.find((item) => item.slug === section)?.title ?? section)
|
|
: "Overview";
|
|
return (
|
|
<div className="min-h-svh bg-background">
|
|
<LiveRefresh endpoint={`/api/events/page/${guide.id}`} />
|
|
<SiteHeader editHref={`/admin/${guide.characterKey}`} />
|
|
<div className="sticky top-0 z-10 bg-background/95 supports-backdrop-filter:backdrop-blur">
|
|
<div className="mx-auto flex min-h-16 w-full max-w-7xl items-center gap-3 px-4 sm:px-6">
|
|
<Link
|
|
href="/"
|
|
aria-label="กลับหน้ารวม"
|
|
className="grid size-10 place-items-center rounded-lg hover:bg-muted"
|
|
>
|
|
<ChevronLeftIcon />
|
|
</Link>
|
|
<div
|
|
className={cn(
|
|
"relative size-11 overflow-hidden rounded-lg border",
|
|
rarityGradientClass(guide.character.rarity),
|
|
)}
|
|
>
|
|
<Image
|
|
src={guide.character.imageUrl}
|
|
alt={guide.name}
|
|
fill
|
|
className="object-contain"
|
|
/>
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
|
<p className="truncate text-sm text-muted-foreground">
|
|
{guide.name}
|
|
</p>
|
|
<p className="truncate font-heading text-2xl font-semibold">
|
|
{currentPageTitle}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<ScrollableNav
|
|
ariaLabel={`เมนูคู่มือ ${guide.name}`}
|
|
className="mx-auto min-h-11 w-full max-w-7xl rounded-xl border bg-card p-2"
|
|
>
|
|
<Link
|
|
href={`/${guide.slug}`}
|
|
className={cn(
|
|
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
!section
|
|
? "bg-muted text-foreground"
|
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
)}
|
|
prefetch
|
|
>
|
|
<Image
|
|
src={navigationIconPath("overview")!}
|
|
alt=""
|
|
width={20}
|
|
height={20}
|
|
className="size-5 object-contain"
|
|
/>
|
|
Overview
|
|
</Link>
|
|
{sections.map((item) => (
|
|
<Link
|
|
key={item.id}
|
|
href={`/${guide.slug}/${item.slug}`}
|
|
className={cn(
|
|
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
section === item.slug
|
|
? "bg-muted text-foreground"
|
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
)}
|
|
prefetch
|
|
>
|
|
{navigationIconPath(item.slug) ? (
|
|
<Image
|
|
src={navigationIconPath(item.slug)!}
|
|
alt=""
|
|
width={20}
|
|
height={20}
|
|
className="size-5 object-contain"
|
|
/>
|
|
) : null}
|
|
{item.title}
|
|
</Link>
|
|
))}
|
|
</ScrollableNav>
|
|
</div>
|
|
<main className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10">
|
|
{section ? (
|
|
<GuideSection guide={guide} section={section} />
|
|
) : (
|
|
<GuideOverview guide={guide} sections={sections} />
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GuideOverview({
|
|
guide,
|
|
sections,
|
|
}: {
|
|
guide: PublicGuide;
|
|
sections: ReturnType<typeof publicSections>;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-8">
|
|
<div className="mx-auto flex max-w-3xl flex-col gap-3 text-center">
|
|
<p className="text-sm font-medium text-primary">ภาพรวมแนวทาง</p>
|
|
<h1 className="font-heading text-4xl font-bold">{guide.name}</h1>
|
|
<p className="text-lg font-semibold">{guide.overview}</p>
|
|
</div>
|
|
<div className="relative mx-auto aspect-square w-full max-w-3xl overflow-hidden rounded-xl border bg-muted">
|
|
<Image
|
|
src={guide.coverUrl}
|
|
alt={`ไกด์ ${guide.name}`}
|
|
fill
|
|
priority
|
|
className="object-cover"
|
|
/>
|
|
</div>
|
|
{sections.length ? (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
{sections.map((item) => (
|
|
<Link key={item.id} href={`/${guide.slug}/${item.slug}`} prefetch>
|
|
<Card className="h-full">
|
|
<CardHeader>
|
|
{navigationIconPath(item.slug) ? (
|
|
<Image
|
|
src={navigationIconPath(item.slug)!}
|
|
alt=""
|
|
width={40}
|
|
height={40}
|
|
className="size-10 object-contain"
|
|
/>
|
|
) : null}
|
|
<CardTitle>
|
|
{publicSectionTitle(item.slug, item.title)}
|
|
</CardTitle>
|
|
<CardDescription>เปิดดูคำแนะนำฉบับเต็ม</CardDescription>
|
|
<CardAction>
|
|
<ArrowUpRightIcon
|
|
aria-hidden="true"
|
|
className="size-5 text-primary"
|
|
/>
|
|
</CardAction>
|
|
</CardHeader>
|
|
</Card>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>กำลังจัดทำเนื้อหา</CardTitle>
|
|
<CardDescription>
|
|
กลับมาตรวจสอบอีกครั้งหลังผู้ดูแลเพิ่มข้อมูล
|
|
</CardDescription>
|
|
</CardHeader>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GuideSection({
|
|
guide,
|
|
section,
|
|
}: {
|
|
guide: PublicGuide;
|
|
section: string;
|
|
}) {
|
|
if (section === "weapon") return <WeaponSection guide={guide} />;
|
|
if (section === "artifact") return <ArtifactSection guide={guide} />;
|
|
if (section === "constellations")
|
|
return <ConstellationSection guide={guide} />;
|
|
if (section === "team") return <TeamSection guide={guide} />;
|
|
const extra = guide.extras.find((item) => item.slug === section);
|
|
return extra ? <ExtraSection extra={extra} /> : null;
|
|
}
|
|
|
|
function metric(value: string | null) {
|
|
return value === null
|
|
? null
|
|
: `${Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 })}%`;
|
|
}
|
|
|
|
function WeaponSection({ guide }: { guide: PublicGuide }) {
|
|
const note = guide.sections.find(
|
|
(section) => section.slug === "weapon",
|
|
)?.note;
|
|
return (
|
|
<section className="flex flex-col gap-6">
|
|
<header className="flex flex-col items-center gap-3 text-center">
|
|
<p className="text-sm font-medium text-primary">อาวุธที่แนะนำ</p>
|
|
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
|
|
Weapons
|
|
</h1>
|
|
{note ? (
|
|
<p className="max-w-3xl whitespace-pre-wrap text-muted-foreground">
|
|
{note}
|
|
</p>
|
|
) : null}
|
|
<a
|
|
href={`/api/guides/${guide.id}/weapons/image`}
|
|
download
|
|
className={buttonVariants({ variant: "outline", size: "sm" })}
|
|
>
|
|
<DownloadIcon data-icon="inline-start" />
|
|
ส่งออกภาพ
|
|
</a>
|
|
</header>
|
|
<div className="grid gap-3">
|
|
{guide.weapons.map((row, index) => (
|
|
<Card key={row.id} size="sm" className="bg-white py-0">
|
|
<CardContent className="grid p-0 md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)]">
|
|
<div
|
|
className={cn(
|
|
"flex min-h-28 min-w-0 items-center gap-4 rounded-br-[4rem] p-4 pr-10 text-white md:rounded-br-[8rem] md:pr-16",
|
|
rarityGradientClass(row.weapon.rarity),
|
|
)}
|
|
>
|
|
<div className="relative size-20 shrink-0">
|
|
<Image
|
|
src={row.weapon.imageUrl}
|
|
alt={row.weapon.name}
|
|
fill
|
|
sizes="(min-width: 768px) 5rem, 4rem"
|
|
className="object-contain"
|
|
/>
|
|
</div>
|
|
<div className="flex min-w-0 flex-col gap-1">
|
|
<p className="truncate text-base font-semibold sm:text-lg">
|
|
{index + 1}. {row.weapon.name} R{row.refinement}
|
|
</p>
|
|
{row.note ? (
|
|
<p className="whitespace-pre-wrap text-sm">{row.note}</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-around gap-4 bg-white px-4 py-5 text-black">
|
|
{row.overallPercent ? (
|
|
<div className="flex-1 text-center">
|
|
<p
|
|
className={cn(
|
|
"whitespace-nowrap text-4xl leading-none font-bold sm:text-5xl",
|
|
rarityTextClass(row.weapon.rarity),
|
|
)}
|
|
>
|
|
{Number(row.overallPercent) === 0
|
|
? "-%"
|
|
: metric(row.overallPercent)}
|
|
</p>
|
|
<p className="mt-1 text-sm sm:text-base">ดาเมททั้งทีม</p>
|
|
</div>
|
|
) : null}
|
|
{row.personalPercent ? (
|
|
<div className="flex-1 text-center">
|
|
<p
|
|
className={cn(
|
|
"whitespace-nowrap text-4xl leading-none font-bold sm:text-5xl",
|
|
rarityTextClass(row.weapon.rarity),
|
|
)}
|
|
>
|
|
{Number(row.personalPercent) === 0
|
|
? "-%"
|
|
: metric(row.personalPercent)}
|
|
</p>
|
|
<p className="mt-1 text-sm sm:text-base">ดาเมจส่วนตัว</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ArtifactSection({ guide }: { guide: PublicGuide }) {
|
|
const profile = guide.artifactProfile;
|
|
return (
|
|
<section className="flex flex-col gap-6">
|
|
<header className="flex flex-col items-center gap-3 text-center">
|
|
<p className="text-sm font-medium text-primary">
|
|
อาร์ติแฟกต์และสแตทที่แนะนำ
|
|
</p>
|
|
<h1 className="font-heading text-4xl font-semibold">
|
|
Artifact & Stats
|
|
</h1>
|
|
<a
|
|
href={`/api/guides/${guide.id}/artifacts/image`}
|
|
download
|
|
className={buttonVariants({ variant: "outline", size: "sm" })}
|
|
>
|
|
<DownloadIcon data-icon="inline-start" />
|
|
ส่งออกภาพ
|
|
</a>
|
|
</header>
|
|
<div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]">
|
|
{" "}
|
|
<div className="flex flex-col gap-5">
|
|
{profile ? (
|
|
<Card className="overflow-hidden pt-0">
|
|
<CardHeader className="bg-primary py-4 text-center text-primary-foreground">
|
|
<CardTitle className="text-2xl">ออฟหลักที่ดีที่สุด</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid grid-cols-3 p-0">
|
|
<ArtifactStat
|
|
iconSrc="/icon/Icon_Sands_of_Eon.webp"
|
|
label="นาฬิกา"
|
|
values={profile.sands}
|
|
/>
|
|
<ArtifactStat
|
|
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp"
|
|
label="ถ้วย"
|
|
values={profile.goblet}
|
|
/>
|
|
<ArtifactStat
|
|
iconSrc="/icon/Icon_Circlet_of_Logos.webp"
|
|
label="หัว"
|
|
values={profile.circlet}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
{profile?.substats.length ? (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center gap-2 py-5 text-center text-sm">
|
|
<strong>ออฟรองที่ดีที่สุด</strong>
|
|
<div className="flex flex-col items-center gap-1">
|
|
{profile.substats.map((stat, index) => (
|
|
<ArtifactStatValue
|
|
key={`${stat}-${index}`}
|
|
value={stat}
|
|
index={index}
|
|
/>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
<div className="order-first flex flex-col gap-5">
|
|
{guide.artifactGroups.map((group) => (
|
|
<Card key={group.id}>
|
|
<CardHeader>
|
|
<CardTitle>
|
|
{group.name === "Late Game"
|
|
? "เลทเกม"
|
|
: group.name === "Early Game"
|
|
? "ต้นเกม"
|
|
: group.name}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-3">
|
|
{group.rows.map((row, index) => (
|
|
<div
|
|
key={row.id}
|
|
className={cn(
|
|
"flex items-center gap-3 rounded-md px-3 py-2",
|
|
index % 2 === 0 ? "bg-muted/60" : "bg-muted/20",
|
|
)}
|
|
>
|
|
<div className="flex -space-x-3">
|
|
<div
|
|
className={cn(
|
|
"relative size-14 overflow-hidden rounded-lg border sm:size-16",
|
|
rarityGradientClass(row.first.rarity),
|
|
)}
|
|
>
|
|
<Image
|
|
src={row.first.imageUrl}
|
|
alt={row.first.name}
|
|
fill
|
|
sizes="(min-width: 640px) 4rem, 3.5rem"
|
|
className="object-contain"
|
|
/>
|
|
</div>
|
|
{row.second ? (
|
|
<div
|
|
className={cn(
|
|
"relative size-14 overflow-hidden rounded-lg border sm:size-16",
|
|
rarityGradientClass(row.second.rarity),
|
|
)}
|
|
>
|
|
<Image
|
|
src={row.second.imageUrl}
|
|
alt={row.second.name}
|
|
fill
|
|
sizes="(min-width: 640px) 4rem, 3.5rem"
|
|
className="object-contain"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium">
|
|
{artifactTierCount(row.second?.name ?? null)}{" "}
|
|
{row.first.name}
|
|
{row.second ? ` + ${row.second.name}` : ""}
|
|
</p>
|
|
{row.note ? (
|
|
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
|
{row.note}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<p className="text-2xl font-semibold text-primary">
|
|
{Number(row.percent) === 0 ? "-%" : metric(row.percent)}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{profile ? (
|
|
<div className="flex flex-col gap-5">
|
|
<Card>
|
|
<CardHeader>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-5">
|
|
{profile.targets.length ? (
|
|
<div>
|
|
<p className="mb-2 text-sm font-medium">ค่าสถานะต่ำๆที่ควรจะปั้นไว้</p>
|
|
<div className="flex flex-col gap-1">
|
|
{profile.targets.map((target, index) => (
|
|
<div
|
|
key={`${target.stat}-${target.value}`}
|
|
className={cn(
|
|
"flex items-center justify-between gap-4 rounded-md px-3 py-2 text-sm",
|
|
index % 2 === 0 ? "bg-muted/60" : "bg-muted/20",
|
|
)}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
{targetStatIconName(target.stat) ? (
|
|
<Image
|
|
src={`/icon/stats/${targetStatIconName(target.stat)}.webp`}
|
|
alt=""
|
|
width={14}
|
|
height={14}
|
|
className="size-3.5 shrink-0 object-contain brightness-0 invert"
|
|
/>
|
|
) : null}
|
|
<span>
|
|
{statLabel(
|
|
normalizeTargetStat(target.stat) ?? target.stat,
|
|
)}
|
|
</span>
|
|
</span>
|
|
<strong className="shrink-0">{target.value}</strong>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{profile.talents.length ? (
|
|
<div>
|
|
<p className="mb-2 text-sm font-medium">
|
|
ลำดับการอัพพรสวรรค์
|
|
</p>
|
|
<div className="flex items-start justify-center gap-1 sm:gap-3">
|
|
{normalizeTalentPriorities(profile.talents).map(
|
|
(talent, index, talents) => {
|
|
const catalogTalent = guide.character.talents.find(
|
|
(item) => item.type === talent.type,
|
|
);
|
|
const talentName = catalogTalent?.name ?? talent.type;
|
|
return (
|
|
<div
|
|
key={talent.type}
|
|
className="flex min-w-0 items-start gap-1 sm:gap-3"
|
|
>
|
|
<div className="flex w-16 flex-col items-center gap-2 text-center sm:w-28">
|
|
<div className="relative size-14 overflow-hidden rounded-xl border bg-muted sm:size-20">
|
|
{catalogTalent?.iconUrl ? (
|
|
<Image
|
|
src={catalogTalent.iconUrl}
|
|
alt={talentName}
|
|
fill
|
|
className="object-contain"
|
|
/>
|
|
) : (
|
|
<span className="grid size-full place-items-center font-semibold">
|
|
{talent.type}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Badge variant="secondary">
|
|
{talent.level
|
|
? `เลเวล ${talent.level}+`
|
|
: "-"}
|
|
</Badge>
|
|
<p className="text-xs font-medium sm:text-sm">
|
|
{talentName}
|
|
</p>
|
|
<p className="text-[11px] text-muted-foreground sm:text-xs">
|
|
{talentTypeLabel(talent.type)}
|
|
</p>
|
|
</div>
|
|
{index < talents.length - 1 ? (
|
|
<span
|
|
className="mt-4 text-xl font-semibold sm:mt-6 sm:text-3xl"
|
|
aria-label={
|
|
talent.relationToNext === "gte"
|
|
? "มากกว่าหรือเท่ากับ"
|
|
: talent.relationToNext === "eq"
|
|
? "เท่ากับ"
|
|
: "มากกว่า"
|
|
}
|
|
>
|
|
{talentRelationSymbol(talent.relationToNext)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
},
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{profile.note ? (
|
|
<div className="pt-3">
|
|
<p className="mb-2 text-sm font-medium">โน็ต</p>
|
|
<p className="text-sm leading-6 text-muted-foreground">
|
|
{profile.note}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function statLabel(value: string): string {
|
|
const labels: Record<string, string> = {
|
|
HP: "พลังชีวิต",
|
|
ATK: "พลังโจมตี",
|
|
DEF: "พลังป้องกัน",
|
|
EM: "ความชำนาญธาตุ",
|
|
ER: "ฟื้นฟูพลังงาน",
|
|
"CRIT RATE": "อัตราคริ",
|
|
"CRIT DAMAGE": "ความแรงคริ",
|
|
"ATK%": "พลังโจมตี%",
|
|
"HP%": "พลังชีวิต%",
|
|
"DEF%": "พลังป้องกัน%",
|
|
"CRIT Rate%/CRIT DMG%": "อัตราคริ/ความแรงคริ",
|
|
"Pyro DMG%": "ความเสียหายธาตุไฟ%",
|
|
"Hydro DMG%": "ความเสียหายธาตุน้ำ%",
|
|
"Cryo DMG%": "ความเสียหายธาตุน้ำแข็ง%",
|
|
"Electro DMG%": "ความเสียหายธาตุไฟฟ้า%",
|
|
"Anemo DMG%": "ความเสียหายธาตุลม%",
|
|
"Geo DMG%": "ความเสียหายธาตุดิน%",
|
|
"Dendro DMG%": "ความเสียหายธาตุไม้%",
|
|
"Physical DMG%": "ความเสียหายกายภาพ%",
|
|
"Healing Bonus%": "โบนัสการรักษา%",
|
|
};
|
|
return labels[value] ?? value;
|
|
}
|
|
|
|
function ArtifactStat({
|
|
iconSrc,
|
|
label,
|
|
values,
|
|
}: {
|
|
iconSrc: string;
|
|
label: string;
|
|
values: string[];
|
|
}) {
|
|
return (
|
|
<div className="flex min-w-0 flex-col items-center gap-3 border-r p-3 text-center last:border-r-0 sm:p-5">
|
|
<Image
|
|
src={iconSrc}
|
|
alt=""
|
|
width={48}
|
|
height={48}
|
|
className="size-12 object-contain"
|
|
/>
|
|
<p className="font-semibold">{label}</p>
|
|
<Separator />
|
|
<div className="flex max-w-full flex-col items-center gap-1 text-xs leading-5 sm:text-sm sm:leading-6">
|
|
{values.length ? (
|
|
values.map((value, index) => (
|
|
<ArtifactStatValue
|
|
key={`${value}-${index}`}
|
|
value={value}
|
|
index={index}
|
|
/>
|
|
))
|
|
) : (
|
|
<span>-</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ArtifactStatValue({ value, index }: { value: string; index: number }) {
|
|
const iconName = recommendedStatIconName(value);
|
|
const hasIcon =
|
|
iconName &&
|
|
targetStatOptions.some((stat) => targetStatIconName(stat) === iconName);
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"flex max-w-full items-center justify-center gap-1 text-center",
|
|
statPriorityColorClass(index),
|
|
)}
|
|
>
|
|
{hasIcon ? (
|
|
<Image
|
|
src={`/icon/stats/${iconName}.webp`}
|
|
alt=""
|
|
width={20}
|
|
height={20}
|
|
className="size-5 shrink-0 object-contain brightness-0 invert"
|
|
/>
|
|
) : null}
|
|
<span className="break-words">{statLabel(value)}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ConstellationSection({ guide }: { guide: PublicGuide }) {
|
|
const note = guide.sections.find(
|
|
(section) => section.slug === "constellations",
|
|
)?.note;
|
|
return (
|
|
<section className="flex flex-col gap-6">
|
|
<header className="flex flex-col items-center gap-3 text-center">
|
|
<p className="text-sm font-medium text-primary">กลุ่มดาว</p>
|
|
<h1 className="font-heading text-4xl font-semibold">กลุ่มดาว</h1>
|
|
{note ? (
|
|
<p className="max-w-3xl whitespace-pre-wrap text-muted-foreground">
|
|
{note}
|
|
</p>
|
|
) : null}
|
|
</header>
|
|
{guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, groupIndex, groups) => (
|
|
<div key={group.id} className="flex flex-col gap-4">
|
|
{groups.length > 1 ? <h2 className="font-heading text-2xl font-semibold">Condition {groupIndex + 1}</h2> : null}
|
|
{group.note ? <p className="whitespace-pre-wrap text-muted-foreground">{group.note}</p> : null}
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{group.rows.map((row) => (
|
|
<Card key={row.level}>
|
|
<CardHeader className="flex flex-row items-center gap-4">
|
|
{row.iconUrl ? (
|
|
<Image
|
|
src={row.iconUrl}
|
|
alt=""
|
|
width={64}
|
|
height={64}
|
|
className="size-16 shrink-0 rounded-lg bg-muted object-contain"
|
|
/>
|
|
) : null}
|
|
<div className="min-w-0">
|
|
<CardTitle
|
|
className={cn(
|
|
"text-3xl text-white",
|
|
row.isGreat && "text-warning",
|
|
)}
|
|
>
|
|
{metric(row.cumulativePercent)}
|
|
</CardTitle>
|
|
<CardDescription className="text-base font-medium text-white">
|
|
C{row.level} · {row.name}
|
|
</CardDescription>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="whitespace-pre-line leading-7">{row.detail}</p>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function TeamSection({ guide }: { guide: PublicGuide }) {
|
|
return (
|
|
<section className="flex flex-col gap-6">
|
|
<header className="text-center">
|
|
<p className="text-sm font-medium text-primary">คำนวณความเสียหาย</p>
|
|
<h1 className="font-heading text-4xl font-semibold">คำนวณทีม</h1>
|
|
</header>
|
|
<TeamSearch teams={guide.teams} />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) {
|
|
return (
|
|
<section className="flex flex-col gap-6">
|
|
<header className="text-center">
|
|
<p className="text-sm font-medium text-primary">คู่มือเสริม</p>
|
|
<h1 className="font-heading text-4xl font-semibold">{extra.title}</h1>
|
|
</header>
|
|
<div className="grid gap-5 md:grid-cols-2">
|
|
{extra.rows.map((row) => (
|
|
<Card key={row.id} className="overflow-hidden py-0">
|
|
{row.imageUrl ? (
|
|
<div
|
|
className={cn(
|
|
"relative bg-muted",
|
|
row.ratio === "1:1" ? "aspect-square" : "aspect-video",
|
|
)}
|
|
>
|
|
<Image
|
|
src={row.imageUrl}
|
|
alt={row.heading}
|
|
fill
|
|
className="object-cover"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<CardHeader className="pt-5">
|
|
<CardTitle>{row.heading}</CardTitle>
|
|
<CardDescription>{row.body}</CardDescription>
|
|
{row.percent ? <Badge>{metric(row.percent)}</Badge> : null}
|
|
</CardHeader>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|