Files
buzz-sheet/components/public/structured-guide.tsx
T
gunshiz 2429b9d319
CI / Verify (push) Successful in 1m6s
CI / Build immutable images and deploy (push) Successful in 2m49s
feat : update
2026-09-05 18:39:03 +07:00

1310 lines
49 KiB
TypeScript

import Image from "next/image";
import Link from "next/link";
import { ArrowUpRightIcon, ChevronLeftIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { SiteFooter } from "@/components/public/site-footer";
import { SiteHeader } from "@/components/public/site-header";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { TeamSearch } from "@/components/public/team-search";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { GuideExportBranding } from "@/components/public/guide-export-branding";
import { ConstellationImage } from "@/components/public/constellation-image";
import { RichTextContent } from "@/components/rich-text-content";
import { LiveRefresh } from "@/components/live-refresh";
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
import { elementIconFilter } from "@/lib/catalog/element";
import type { PublicGuide } from "@/lib/guides/queries";
import { artifactTierCount } from "@/lib/guides/artifacts";
import { navigationIconPath } from "@/lib/navigation";
import {
normalizeTargetStat,
recommendedStatElement,
recommendedStatIconName,
statPriorityColorClass,
targetStatIconName,
} from "@/lib/guides/stats";
import {
normalizeTalentPriorities,
talentRelationSymbol,
talentTypeLabel,
} from "@/lib/guides/talents";
import { parseYouTubeUrl } from "@/lib/guides/youtube";
import { normalizeExtraRichText } from "@/lib/rich-text";
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="flex min-h-svh flex-col bg-background">
<LiveRefresh endpoint={`/api/events/page/${guide.id}`} />
<SiteHeader editHref={`/admin/${guide.characterKey}`} guideId={guide.id} />
<div className="sticky top-0 z-40 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 transition-colors 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 transition-colors 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>
<SiteFooter />
</div>
);
}
function GuideOverview({
guide,
sections,
}: {
guide: PublicGuide;
sections: ReturnType<typeof publicSections>;
}) {
const youtubeVideo = guide.youtubeUrl
? parseYouTubeUrl(guide.youtubeUrl)
: null;
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="grid items-start gap-4 lg:grid-cols-2">
<div className="relative aspect-square w-full overflow-hidden rounded-xl border bg-muted">
<Image
src={guide.coverUrl}
alt={`ไกด์ ${guide.name}`}
fill
priority
className="object-cover"
/>
</div>
{sections.length ? (
<nav
aria-label={`เมนูคู่มือ ${guide.name}`}
className="flex flex-col gap-3"
>
{sections.map((item) => (
<Link
key={item.id}
href={`/${guide.slug}/${item.slug}`}
className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
prefetch
>
<Card size="sm" className="transition-colors hover:bg-muted/50">
<CardHeader>
{navigationIconPath(item.slug) ? (
<Image
src={navigationIconPath(item.slug)!}
alt=""
width={32}
height={32}
className="size-8 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>
))}
</nav>
) : (
<Card>
<CardHeader>
<CardTitle>กำลังจัดทำเนื้อหา</CardTitle>
<CardDescription>
กลับมาตรวจสอบอีกครั้งหลังผู้ดูแลเพิ่มข้อมูล
</CardDescription>
</CardHeader>
</Card>
)}
</div>
{youtubeVideo ? (
<section className="flex flex-col items-center gap-3">
<div className="aspect-video w-full overflow-hidden rounded-xl border bg-muted">
<iframe
src={youtubeVideo.embedUrl}
title={`วิดีโอไกด์ ${guide.name}`}
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
className="size-full"
/>
</div>
<Button
variant="outline"
render={
<a
href={youtubeVideo.watchUrl}
target="_blank"
rel="noopener noreferrer"
/>
}
nativeButton={false}
>
ดูวิดีโอต้นฉบับบน YouTube
<ArrowUpRightIcon data-icon="inline-end" />
</Button>
</section>
) : null}
</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 guide={guide} extra={extra} /> : null;
}
function metric(value: string | null) {
return value === null
? null
: `${Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 })}%`;
}
function lunarisItemUrl(
kind: "character" | "weapon" | "artifact",
key: string,
): string {
const path = kind === "character" ? "char" : kind;
return `https://lunaris.moe/${path}/${encodeURIComponent(key)}`;
}
function WeaponSection({ guide }: { guide: PublicGuide }) {
return <WeaponSectionContent guide={guide} showExportActions />;
}
function WeaponSectionContent({
guide,
exporting = false,
groups: providedGroups,
conditionNumber,
showExportActions = false,
}: {
guide: PublicGuide;
exporting?: boolean;
groups?: PublicGuide["weaponGroups"];
conditionNumber?: number;
showExportActions?: boolean;
}) {
const note = guide.sections.find(
(section) => section.slug === "weapon",
)?.note;
const weaponGroups = (providedGroups ?? guide.weaponGroups).filter(
(group) => group.rows.length > 0,
);
const useCompactExportGrid =
exporting &&
weaponGroups.reduce((total, group) => total + group.rows.length, 0) > 6;
return (
<section
className={cn(
"flex flex-col gap-6",
exporting &&
"relative isolate overflow-hidden bg-[#070b20] p-10 text-white",
)}
>
{exporting ? (
<GuideExportBranding
backgroundUrl={guide.character.splashImageUrl ?? guide.coverUrl}
/>
) : null}
<header
className={cn(
"relative z-10 flex items-center justify-center gap-3 text-center",
!exporting && "flex-col",
)}
>
{exporting ? (
<Image
src="/icon/nav/Weapon.png"
alt=""
width={56}
height={56}
loading="eager"
className="size-14 shrink-0 object-contain drop-shadow-[0_4px_3px_rgba(0,0,0,0.8)]"
/>
) : null}
<div className="flex flex-col items-center gap-3">
<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}
</div>
</header>
{weaponGroups.map((group, groupIndex, groups) => (
<div key={group.id} className="relative z-10 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
{groups.length > 1 || conditionNumber ? (
<h2 className="font-heading text-2xl font-semibold">
Condition {conditionNumber ?? groupIndex + 1}
</h2>
) : <span />}
{showExportActions ? (
<GuideImageExport
fileName={`${guide.name}-Weapon-Condition-${groupIndex + 1}.png`}
captureClassName="p-0"
>
<WeaponSectionContent
guide={guide}
exporting
groups={[group]}
conditionNumber={groups.length > 1 ? groupIndex + 1 : undefined}
/>
</GuideImageExport>
) : null}
</div>
{group.note ? <p className="whitespace-pre-wrap text-muted-foreground">{group.note}</p> : null}
<div
className={cn("grid gap-3", useCompactExportGrid && "grid-cols-2")}
>
{group.rows.map((row, index) => (
<Card key={row.id} size="sm" className="bg-white py-0">
<CardContent
className={cn(
"grid p-0",
useCompactExportGrid
? "grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"
: exporting
? "grid-cols-[minmax(0,3fr)_minmax(0,1fr)]"
: "md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)]",
)}
>
<a
href={lunarisItemUrl("weapon", row.weapon.key)}
target="_blank"
rel="noreferrer"
tabIndex={exporting ? -1 : undefined}
aria-label={`เปิดข้อมูลอาวุธ ${row.weapon.name} บน Lunaris`}
className={cn(
"flex min-h-28 min-w-0 items-center gap-4 rounded-br-[4rem] p-4 pr-10 text-white",
useCompactExportGrid
? "rounded-br-[8rem] pr-10"
: exporting
? "rounded-br-[8rem] pr-16"
: "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
loading={exporting ? "eager" : undefined}
sizes="(min-width: 768px) 5rem, 4rem"
className="object-contain"
/>
</div>
<div className="flex min-w-0 flex-col gap-1">
<p
className={cn(
"truncate font-semibold",
useCompactExportGrid
? "text-sm"
: exporting
? "text-lg"
: "text-base sm:text-lg",
)}
>
{index + 1}. {row.weapon.name} R{row.refinement}
</p>
{row.note ? (
<p
className={cn(
"whitespace-pre-wrap",
useCompactExportGrid ? "text-xs" : "text-sm",
)}
>
{row.note}
</p>
) : null}
</div>
</a>
<div
className={cn(
"flex items-center justify-around bg-white py-5 text-black",
useCompactExportGrid ? "gap-1 px-2" : "gap-4 px-4",
)}
>
{row.overallPercent ? (
<div className="min-w-0 flex-1 text-center">
<p
className={cn(
"whitespace-nowrap leading-none font-bold",
useCompactExportGrid
? "text-2xl"
: exporting
? "text-5xl"
: "text-4xl sm:text-5xl",
rarityTextClass(row.weapon.rarity),
)}
>
{Number(row.overallPercent) === 0
? "-%"
: metric(row.overallPercent)}
</p>
<p
className={cn(
"mt-1 whitespace-nowrap",
useCompactExportGrid
? "text-[10px]"
: exporting
? "text-base"
: "text-sm sm:text-base",
)}
>
ดาเมททั้งทีม
</p>
</div>
) : null}
{row.personalPercent ? (
<div className="min-w-0 flex-1 text-center">
<p
className={cn(
"whitespace-nowrap leading-none font-bold",
useCompactExportGrid
? "text-2xl"
: exporting
? "text-5xl"
: "text-4xl sm:text-5xl",
rarityTextClass(row.weapon.rarity),
)}
>
{Number(row.personalPercent) === 0
? "-%"
: metric(row.personalPercent)}
</p>
<p
className={cn(
"mt-1 whitespace-nowrap",
useCompactExportGrid
? "text-[10px]"
: exporting
? "text-base"
: "text-sm sm:text-base",
)}
>
ดาเมจส่วนตัว
</p>
</div>
) : null}
</div>
</CardContent>
</Card>
))}
</div>
</div>
))}
</section>
);
}
function ArtifactSection({ guide }: { guide: PublicGuide }) {
return (
<ArtifactSectionContent
guide={guide}
action={
<GuideImageExport
fileName={`${guide.name}-Artifact.png`}
captureClassName="p-0"
>
<ArtifactSectionContent guide={guide} exporting />
</GuideImageExport>
}
/>
);
}
function ArtifactSectionContent({
guide,
action,
exporting = false,
}: {
guide: PublicGuide;
action?: ReactNode;
exporting?: boolean;
}) {
const profile = guide.artifactProfile;
return (
<section
className={cn(
"flex flex-col gap-6",
exporting &&
"relative isolate overflow-hidden bg-[#070b20] p-10 text-white",
)}
>
{exporting ? (
<GuideExportBranding
backgroundUrl={guide.character.splashImageUrl ?? guide.coverUrl}
/>
) : null}
<header
className={cn(
"relative z-10 flex items-center justify-center gap-3 text-center",
!exporting && "flex-col",
)}
>
{exporting ? (
<Image
src="/icon/nav/Artifact.webp"
alt=""
width={56}
height={56}
loading="eager"
className="size-14 shrink-0 object-contain drop-shadow-[0_4px_3px_rgba(0,0,0,0.8)]"
/>
) : null}
<div className="flex flex-col items-center gap-3">
<p className="text-sm font-medium text-primary">
อาร์ติแฟกต์และสแตทที่แนะนำ
</p>
<h1 className="font-heading text-4xl font-semibold">
Artifact & Stats
</h1>
{action}
</div>
</header>
<div
className={cn(
"relative z-10 grid gap-5",
exporting ? "grid-cols-[1.2fr_1fr]" : "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}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp"
label="ถ้วย"
values={profile.goblet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Circlet_of_Logos.webp"
label="หัว"
values={profile.circlet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
</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}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
))}
</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">
<a
href={lunarisItemUrl("artifact", row.first.key)}
target="_blank"
rel="noreferrer"
tabIndex={exporting ? -1 : undefined}
aria-label={`เปิดข้อมูลอาร์ติแฟกต์ ${row.first.name} บน Lunaris`}
className={cn(
"relative overflow-hidden rounded-lg border",
exporting ? "size-16" : "size-14 sm:size-16",
rarityGradientClass(row.first.rarity),
)}
>
<Image
src={row.first.imageUrl}
alt={row.first.name}
fill
loading={exporting ? "eager" : undefined}
sizes="(min-width: 640px) 4rem, 3.5rem"
className="object-contain"
/>
{!exporting ? (
<Kbd className="absolute top-0 right-0 h-3 min-w-3 bg-transparent p-0 text-white">
<ArrowUpRightIcon aria-hidden="true" />
</Kbd>
) : null}
</a>
{row.second ? (
<a
href={lunarisItemUrl("artifact", row.second.key)}
target="_blank"
rel="noreferrer"
tabIndex={exporting ? -1 : undefined}
aria-label={`เปิดข้อมูลอาร์ติแฟกต์ ${row.second.name} บน Lunaris`}
className={cn(
"relative overflow-hidden rounded-lg border",
exporting ? "size-16" : "size-14 sm:size-16",
rarityGradientClass(row.second.rarity),
)}
>
<Image
src={row.second.imageUrl}
alt={row.second.name}
fill
loading={exporting ? "eager" : undefined}
sizes="(min-width: 640px) 4rem, 3.5rem"
className="object-contain"
/>
{!exporting ? (
<Kbd className="absolute top-0 right-0 h-3 min-w-3 bg-transparent p-0 text-white">
<ArrowUpRightIcon aria-hidden="true" />
</Kbd>
) : null}
</a>
) : 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}
loading={exporting ? "eager" : undefined}
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={cn(
"flex items-start justify-center",
exporting ? "gap-3" : "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={cn(
"flex min-w-0 items-start",
exporting ? "gap-3" : "gap-1 sm:gap-3",
)}
>
<div
className={cn(
"flex flex-col items-center gap-2 text-center",
exporting ? "w-28" : "w-16 sm:w-28",
)}
>
<div
className={cn(
"relative overflow-hidden rounded-xl border bg-muted",
exporting ? "size-20" : "size-14 sm:size-20",
)}
>
{catalogTalent?.iconUrl ? (
<Image
src={catalogTalent.iconUrl}
alt={talentName}
fill
loading={exporting ? "eager" : undefined}
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={cn(
"font-medium",
exporting ? "text-sm" : "text-xs sm:text-sm",
)}
>
{talentName}
</p>
<p
className={cn(
"text-muted-foreground",
exporting ? "text-xs" : "text-[11px] sm:text-xs",
)}
>
{talentTypeLabel(talent.type)}
</p>
</div>
{index < talents.length - 1 ? (
<span
className={cn(
"font-semibold",
exporting
? "mt-6 text-3xl"
: "mt-4 text-xl 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,
exporting = false,
elementIconUrls,
}: {
iconSrc: string;
label: string;
values: string[];
exporting?: boolean;
elementIconUrls: Record<string, string>;
}) {
return (
<div
className={cn(
"flex min-w-0 flex-col items-center gap-3 border-r text-center last:border-r-0",
exporting ? "p-5" : "p-3 sm:p-5",
)}
>
<Image
src={iconSrc}
alt=""
width={48}
height={48}
loading={exporting ? "eager" : undefined}
className="size-12 object-contain"
/>
<p className="font-semibold">{label}</p>
<Separator />
<div
className={cn(
"flex max-w-full flex-col items-center gap-1",
exporting
? "text-sm leading-6"
: "text-xs leading-5 sm:text-sm sm:leading-6",
)}
>
{values.length ? (
values.map((value, index) => (
<ArtifactStatValue
key={`${value}-${index}`}
value={value}
index={index}
exporting={exporting}
elementIconUrls={elementIconUrls}
/>
))
) : (
<span>-</span>
)}
</div>
</div>
);
}
function ArtifactStatValue({
value,
index,
exporting = false,
elementIconUrls,
}: {
value: string;
index: number;
exporting?: boolean;
elementIconUrls: Record<string, string>;
}) {
const iconName = recommendedStatIconName(value);
const element = recommendedStatElement(value);
const elementIconUrl = element ? elementIconUrls[element] : null;
const hasIcon = Boolean(iconName);
return (
<span
className={cn(
"flex max-w-full items-center justify-center gap-1 text-center",
statPriorityColorClass(index),
)}
>
{elementIconUrl ? (
<Image
src={elementIconUrl}
alt=""
width={20}
height={20}
loading={exporting ? "eager" : undefined}
className="size-5 shrink-0 object-contain"
style={{ filter: elementIconFilter(element!) }}
/>
) : hasIcon ? (
<Image
src={`/icon/stats/${iconName}.${iconName === "elemental-bonus" ? "svg" : "webp"}`}
alt=""
width={20}
height={20}
loading={exporting ? "eager" : undefined}
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">
<div>
<GuideImageExport
fileName={`${guide.name}-Constellations${groups.length > 1 ? `-Condition-${groupIndex + 1}` : ""}.png`}
width={1500}
height={1500}
captureClassName="p-0"
>
<ConstellationImage
guide={guide}
group={group}
sectionNote={note}
/>
</GuideImageExport>
</div>
{groups.length > 1 ? <h2 className="font-heading text-2xl font-semibold">Condition {groupIndex + 1}</h2> : null}
{group.note ? <p className="whitespace-pre-wrap">{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({
guide,
extra,
}: {
guide: PublicGuide;
extra: PublicGuide["extras"][number];
}) {
return (
<ExtraSectionContent
guide={guide}
extra={extra}
action={
<GuideImageExport fileName={`${extra.title}.png`} captureClassName="p-0">
<ExtraSectionContent guide={guide} extra={extra} exporting />
</GuideImageExport>
}
/>
);
}
function ExtraSectionContent({
guide,
extra,
action,
exporting = false,
}: {
guide: PublicGuide;
extra: PublicGuide["extras"][number];
action?: React.ReactNode;
exporting?: boolean;
}) {
return (
<section className={cn("flex flex-col gap-6", exporting && "relative isolate overflow-hidden bg-[#070b20] p-10 text-white")}>
{exporting ? (
<GuideExportBranding
backgroundUrl={guide.character.splashImageUrl ?? guide.coverUrl}
/>
) : null}
<header className="relative z-10 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">{extra.title}</h1>
{action}
</header>
<div className={cn("relative z-10 grid gap-5 md:grid-cols-2", exporting && "grid-cols-2")}>
{extra.rows.map((row) => (
<Card
key={row.id}
className={cn(
"overflow-hidden py-0",
row.sheet && (exporting ? "col-span-2" : "md:col-span-2"),
)}
style={row.backgroundColor ? { backgroundColor: row.backgroundColor } : undefined}
>
{row.imageUrl ? (
<div
className="relative bg-muted"
style={{
aspectRatio:
row.imageWidth && row.imageHeight
? `${row.imageWidth} / ${row.imageHeight}`
: "16 / 9",
}}
>
<Image
src={row.imageUrl}
alt={row.heading}
fill
className="object-cover"
/>
</div>
) : null}
{row.catalogItems.length ? (
<CardContent className="grid grid-cols-2 gap-2 pt-5">
{row.catalogItems.map((item) => (
<a
key={`${item.kind}:${item.key}`}
href={lunarisItemUrl(item.kind, item.key)}
target="_blank"
rel="noreferrer"
tabIndex={exporting ? -1 : undefined}
aria-label={`เปิดข้อมูล ${item.name} บน Lunaris`}
className="flex min-w-0 items-center gap-2 rounded-lg border bg-background/80 p-2 outline-none hover:bg-background focus-visible:ring-2 focus-visible:ring-ring"
>
{item.imageUrl ? (
<div className={cn("relative size-12 shrink-0 overflow-hidden rounded-md", rarityGradientClass(item.rarity))}>
<Image src={item.imageUrl} alt="" fill className="object-contain" />
</div>
) : null}
<div className="min-w-0">
<p className="truncate text-sm font-medium">{item.name}</p>
{!item.available ? <Badge variant="secondary">ไม่พบรายการ</Badge> : null}
</div>
<ArrowUpRightIcon aria-hidden="true" className="ml-auto shrink-0" />
</a>
))}
</CardContent>
) : null}
<CardHeader className="pt-5">
<CardTitle>{row.heading}</CardTitle>
<CardDescription>
<RichTextContent html={normalizeExtraRichText(row.body, row.highlights)} />
</CardDescription>
{row.percent ? <Badge>{metric(row.percent)}</Badge> : null}
</CardHeader>
{row.sheet ? (
<CardContent
className={cn(
"pb-5",
exporting && "[&_[data-slot=table-container]]:overflow-visible",
)}
>
<Table
className={cn(exporting && "table-fixed")}
style={exporting ? undefined : { minWidth: `${row.sheet.columns.length * 140}px` }}
>
<TableHeader>
<TableRow>
{row.sheet.columns.map((column) => (
<TableHead key={column.id} className="border-l first:border-l-0 whitespace-normal">
{column.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{row.sheet.rows.map((sheetRow) => (
<TableRow key={sheetRow.id}>
{row.sheet!.columns.map((column) => (
<TableCell
key={column.id}
className="border-l first:border-l-0 whitespace-normal"
style={sheetRow.backgrounds[column.id]
? { backgroundColor: sheetRow.backgrounds[column.id] }
: undefined}
>
<RichTextContent html={normalizeExtraRichText(sheetRow.cells[column.id] ?? "")} />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
) : null}
</Card>
))}
</div>
</section>
);
}