Files
buzz-sheet/components/public/structured-guide.tsx
T
dmgnr e45bd36f5f
CI / Verify (push) Successful in 57s
CI / Build immutable images and deploy (push) Successful in 1m35s
feat: prefetch subpage links
2026-08-31 18:07:07 +07:00

592 lines
25 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, statPriorityColorClass, targetStatIconName } 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.constellations.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="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>
<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-semibold">{guide.name}</h1>
<p className="text-lg leading-8 text-muted-foreground">
{guide.overview}
</p>
</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 }) {
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">อาวุธ</h1>
<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
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),
)}
>
{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),
)}
>
{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">
อาร์ติแฟกต์และค่าสถานะ
</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">
{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",
rarityGradientClass(row.first.rarity),
)}
>
<Image
src={row.first.imageUrl}
alt={row.first.name}
fill
className="object-contain"
/>
</div>
{row.second ? (
<div
className={cn(
"relative size-14 overflow-hidden rounded-lg border",
rarityGradientClass(row.second.rarity),
)}
>
<Image
src={row.second.imageUrl}
alt={row.second.name}
fill
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">
{metric(row.percent)}
</p>
</div>
))}
</CardContent>
</Card>
))}
</div>
{profile ? (
<div className="flex flex-col gap-5">
<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>
{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) => (
<span key={`${stat}-${index}`} className={statPriorityColorClass(index)}>
{statLabel(stat)}
</span>
))}
</div>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader><CardTitle>รายละเอียดเพิ่มเติม</CardTitle></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={28} height={28} className="size-7 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" ? "มากกว่าหรือเท่ากับ" : "มากกว่า"}>
{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) => (
<span key={`${value}-${index}`} className={cn("max-w-full break-words text-center", statPriorityColorClass(index))}>
{statLabel(value)}
</span>
))
: <span>—</span>}
</div>
</div>
);
}
function ConstellationSection({ 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>
<div className="grid gap-4 md:grid-cols-2">
{guide.constellations.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>
</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>
);
}