1403 lines
57 KiB
TypeScript
1403 lines
57 KiB
TypeScript
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { ArrowLeftIcon, ArrowUpRightIcon } from "lucide-react";
|
|
import type { ReactNode } from "react";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
import { Button } from "@/components/ui/button";
|
|
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 { TeamArtifactIcon, TeamSearch } from "@/components/public/team-search";
|
|
import { GuideImageExport } from "@/components/public/guide-image-export";
|
|
import { ExportText } from "@/components/public/export-language";
|
|
import { WeaponImageExport } from "@/components/public/weapon-image-export";
|
|
import { WeaponDisplayMode, WeaponDisplayModeProvider } from "@/components/public/weapon-display-mode";
|
|
import { ArtifactImageExport } from "@/components/public/artifact-image-export";
|
|
import { GuideExportBranding } from "@/components/public/guide-export-branding";
|
|
import { ConstellationImage } from "@/components/public/constellation-image";
|
|
import { ConditionSwitcher } from "@/components/public/condition-switcher";
|
|
import { MaterialPoster } from "@/components/public/material-poster";
|
|
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,
|
|
} from "@/lib/guides/talents";
|
|
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
|
import { normalizeExtraRichText } from "@/lib/rich-text";
|
|
import { publicGuideSections } from "@/lib/guides/public-sections";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export function publicSections(guide: PublicGuide) {
|
|
return publicGuideSections(guide);
|
|
}
|
|
|
|
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,
|
|
preview = false,
|
|
}: {
|
|
guide: PublicGuide;
|
|
section?: string;
|
|
preview?: boolean;
|
|
}) {
|
|
const sections = publicSections(guide);
|
|
const baseHref = preview ? `/admin/${guide.characterKey}/preview` : `/${guide.slug}`;
|
|
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} />
|
|
{preview ? (
|
|
<div className="mx-auto w-full max-w-7xl px-4 pt-4 sm:px-6 lg:px-8">
|
|
<Alert>
|
|
<AlertTitle>Preview</AlertTitle>
|
|
<AlertDescription>หน้านี้แสดงข้อมูลที่บันทึกล่าสุดและเปิดดูได้เฉพาะผู้ดูแล</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
) : null}
|
|
<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 lg:px-8">
|
|
<Button variant="ghost" size="icon" render={<Link href={preview ? `/admin/${guide.characterKey}` : "/"} />} nativeButton={false} aria-label={preview ? "กลับหน้าแก้ไข" : "กลับหน้ารวม"}>
|
|
<ArrowLeftIcon />
|
|
</Button>
|
|
<div className="relative size-16 overflow-hidden rounded-lg border">
|
|
<Image
|
|
src={guide.coverUrl}
|
|
alt={guide.name}
|
|
fill
|
|
sizes="64px"
|
|
className="object-cover"
|
|
/>
|
|
</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>
|
|
<div className="mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
<ScrollableNav
|
|
ariaLabel={`เมนูคู่มือ ${guide.name}`}
|
|
className="min-h-11 w-full rounded-xl border bg-card p-2"
|
|
>
|
|
<Link
|
|
href={baseHref}
|
|
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={false}
|
|
>
|
|
<Image
|
|
src={navigationIconPath("overview")!}
|
|
alt=""
|
|
width={20}
|
|
height={20}
|
|
className="size-5 object-contain"
|
|
/>
|
|
Overview
|
|
</Link>
|
|
{sections.map((item) => (
|
|
<Link
|
|
key={item.id}
|
|
href={`${baseHref}/${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={false}
|
|
>
|
|
{navigationIconPath(item.slug) ? (
|
|
<Image
|
|
src={navigationIconPath(item.slug)!}
|
|
alt=""
|
|
width={20}
|
|
height={20}
|
|
className="size-5 object-contain"
|
|
/>
|
|
) : null}
|
|
{item.title}
|
|
</Link>
|
|
))}
|
|
</ScrollableNav>
|
|
</div>
|
|
</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 youtubeVideos = guide.youtubeUrls.flatMap((url) => {
|
|
const video = parseYouTubeUrl(url);
|
|
return video ? [video] : [];
|
|
});
|
|
|
|
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 className="flex flex-wrap justify-center gap-2">
|
|
{guide.publishedCatalogVersion ? <Badge variant="outline">เวอร์ชัน {guide.publishedCatalogVersion}</Badge> : null}
|
|
<Badge variant="secondary">
|
|
อัปเดต {new Date(guide.publishedAt ?? guide.updatedAt).toLocaleDateString("th-TH")}
|
|
</Badge>
|
|
</div>
|
|
{guide.publicationNote ? <p className="text-sm text-muted-foreground">{guide.publicationNote}</p> : null}
|
|
</div>
|
|
<div className="grid items-start gap-6 lg:grid-cols-2">
|
|
<div className="order-1 relative aspect-square w-full overflow-hidden rounded-xl border bg-muted lg:col-start-1 lg:row-start-1">
|
|
<Image
|
|
src={guide.coverUrl}
|
|
alt={`ไกด์ ${guide.name}`}
|
|
fill
|
|
sizes="(min-width: 1280px) 600px, (min-width: 1024px) calc(50vw - 44px), calc(100vw - 32px)"
|
|
quality={75}
|
|
fetchPriority="high"
|
|
className="object-cover"
|
|
/>
|
|
</div>
|
|
<div className="order-3 lg:col-start-2 lg:row-start-1">
|
|
<GuideNavigation guide={guide} sections={sections} />
|
|
</div>
|
|
{guide.posterArtUrl || youtubeVideos.length ? (
|
|
<>
|
|
{guide.posterArtUrl ? (
|
|
<section className="order-2 flex flex-col items-center gap-3 lg:col-start-1 lg:row-start-2">
|
|
<MaterialPoster
|
|
character={guide.character}
|
|
characterArtUrl={guide.posterArtUrl}
|
|
artworkOffsetX={guide.posterArtOffsetX}
|
|
artworkOffsetY={guide.posterArtOffsetY}
|
|
artworkScale={guide.posterArtScale}
|
|
artworkRotation={guide.posterArtRotation}
|
|
topColors={guide.posterTopColors}
|
|
bottomColors={guide.posterBottomColors}
|
|
bleeding={guide.posterBleeding}
|
|
linkMaterials
|
|
/>
|
|
<GuideImageExport
|
|
width={1600}
|
|
height={2000}
|
|
format="png"
|
|
fileName={`${guide.name}-materials.png`}
|
|
allowWithoutNotes={false}
|
|
showLanguageSelector={false}
|
|
localizeFileName={false}
|
|
captureClassName="p-0"
|
|
>
|
|
<MaterialPoster
|
|
character={guide.character}
|
|
characterArtUrl={guide.posterArtUrl}
|
|
artworkOffsetX={guide.posterArtOffsetX}
|
|
artworkOffsetY={guide.posterArtOffsetY}
|
|
artworkScale={guide.posterArtScale}
|
|
artworkRotation={guide.posterArtRotation}
|
|
topColors={guide.posterTopColors}
|
|
bottomColors={guide.posterBottomColors}
|
|
bleeding={guide.posterBleeding}
|
|
/>
|
|
</GuideImageExport>
|
|
</section>
|
|
) : null}
|
|
{youtubeVideos.length ? (
|
|
<div className="order-4 flex flex-col gap-6 lg:col-start-2 lg:row-start-2">
|
|
{youtubeVideos.map((youtubeVideo, index) => (
|
|
<section key={`${youtubeVideo.videoId}-${index}`} 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} ${index + 1}`}
|
|
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>
|
|
</section>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GuideNavigation({
|
|
guide,
|
|
sections,
|
|
}: {
|
|
guide: PublicGuide;
|
|
sections: ReturnType<typeof publicSections>;
|
|
}) {
|
|
return 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={false}
|
|
>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
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, locale?: string) {
|
|
return value === null
|
|
? null
|
|
: `${Number(value).toLocaleString(locale, { maximumFractionDigits: 2 })}%`;
|
|
}
|
|
|
|
function exportLabel(exporting: boolean, th: string, en: string): ReactNode {
|
|
return exporting ? <ExportText th={th} en={en} /> : th;
|
|
}
|
|
|
|
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(
|
|
"artifact-export-root 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 items-center justify-center text-center">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<p className="text-sm font-medium text-primary">
|
|
อาวุธที่แนะนำ
|
|
</p>
|
|
<div className="flex items-center justify-center gap-3">
|
|
<Image
|
|
src="/icon/nav/Weapon.png"
|
|
alt=""
|
|
width={56}
|
|
height={56}
|
|
loading={exporting ? "eager" : "lazy"}
|
|
className={cn(
|
|
"size-14 shrink-0 object-contain",
|
|
exporting &&
|
|
"drop-shadow-[0_4px_3px_rgba(0,0,0,0.8)]",
|
|
)}
|
|
/>
|
|
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
|
|
Weapons
|
|
</h1>
|
|
</div>
|
|
{note ? (
|
|
<p className="max-w-3xl whitespace-pre-wrap text-muted-foreground">
|
|
{note}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</header>
|
|
<WeaponDisplayModeProvider>
|
|
<ConditionSwitcher
|
|
label="เลือกเงื่อนไขอาวุธ"
|
|
labels={weaponGroups.map((group, index) => group.name || (exporting ? `Condition ${index + 1}` : `เงื่อนไข ${index + 1}`))}
|
|
>
|
|
{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">
|
|
{group.name || (exporting ? `Condition ${conditionNumber ?? groupIndex + 1}` : `เงื่อนไข ${conditionNumber ?? groupIndex + 1}`)}
|
|
</h2>
|
|
) : <span />}
|
|
{showExportActions ? (
|
|
<WeaponImageExport
|
|
guide={{ name: guide.name, coverUrl: guide.coverUrl, character: { name: guide.character.name, splashImageUrl: guide.character.splashImageUrl } }}
|
|
group={group}
|
|
conditionIndex={groupIndex}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
{group.note ? <p className="whitespace-pre-wrap text-muted-foreground">{group.note}</p> : null}
|
|
<WeaponDisplayMode exporting={exporting} rows={group.rows}>
|
|
<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>
|
|
</WeaponDisplayMode>
|
|
</div>
|
|
))}
|
|
</ConditionSwitcher>
|
|
</WeaponDisplayModeProvider>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ArtifactSection({ guide }: { guide: PublicGuide }) {
|
|
const targetConditions = guide.artifactProfile?.targetConditions.filter(
|
|
(group) => group.rows.length,
|
|
) ?? [];
|
|
return (
|
|
<ArtifactSectionContent
|
|
guide={guide}
|
|
action={
|
|
<ArtifactImageExport
|
|
fileName={`${guide.name}-Artifact.png`}
|
|
hasNote={Boolean(guide.artifactProfile?.note)}
|
|
recommendedConditionLabels={(guide.artifactProfile?.recommendedConditions ?? []).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
|
|
targetConditionLabels={targetConditions.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
|
|
tierConditionLabels={guide.artifactGroups.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
|
|
>
|
|
<ArtifactSectionContent guide={guide} exporting />
|
|
</ArtifactImageExport>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ArtifactSectionContent({
|
|
guide,
|
|
action,
|
|
exporting = false,
|
|
}: {
|
|
guide: PublicGuide;
|
|
action?: ReactNode;
|
|
exporting?: boolean;
|
|
}) {
|
|
const profile = guide.artifactProfile;
|
|
const recommendedConditions = profile?.recommendedConditions ?? [];
|
|
const recommendedCards = recommendedConditions.map((condition, conditionIndex) => (
|
|
<div key={conditionIndex} data-artifact-export-section="best-stats" data-recommended-condition={conditionIndex} className="flex flex-col gap-5">
|
|
{exporting && recommendedConditions.length > 1 ? <p className="text-sm font-medium">{condition.name || <ExportText th={`เงื่อนไข ${conditionIndex + 1}`} en={`Condition ${conditionIndex + 1}`} />}</p> : null}
|
|
{condition.note ? <p data-export-note className="whitespace-pre-wrap text-sm text-muted-foreground">{condition.note}</p> : null}
|
|
{profile ? (
|
|
<Card data-artifact-export-section="best-stats" className="artifact-export-main-stats overflow-hidden pt-0">
|
|
<CardHeader className="bg-primary py-4 text-center text-primary-foreground">
|
|
<CardTitle className="text-2xl">{exportLabel(exporting, "ออฟหลักที่ดีที่สุด", "Best Main Stats")}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid grid-cols-3 p-0">
|
|
<ArtifactStat
|
|
iconSrc="/icon/Icon_Sands_of_Eon.webp"
|
|
label={exportLabel(exporting, "นาฬิกา", "Sands")}
|
|
values={condition.sands}
|
|
exporting={exporting}
|
|
elementIconUrls={guide.elementIconUrls}
|
|
/>
|
|
<ArtifactStat
|
|
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp"
|
|
label={exportLabel(exporting, "ถ้วย", "Goblet")}
|
|
values={condition.goblet}
|
|
exporting={exporting}
|
|
elementIconUrls={guide.elementIconUrls}
|
|
/>
|
|
<ArtifactStat
|
|
iconSrc="/icon/Icon_Circlet_of_Logos.webp"
|
|
label={exportLabel(exporting, "หัว", "Circlet")}
|
|
values={condition.circlet}
|
|
exporting={exporting}
|
|
elementIconUrls={guide.elementIconUrls}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
{condition.substats.length ? (
|
|
<Card data-artifact-export-section="best-stats" className="artifact-export-substats">
|
|
<CardContent className="flex flex-col items-center gap-2 py-5 text-center text-sm">
|
|
<strong>{exportLabel(exporting, "ออฟรองที่ดีที่สุด", "Best Substats")}</strong>
|
|
<div className="flex flex-col items-center gap-1">
|
|
{condition.substats.map((stat, index) => (
|
|
<ArtifactStatValue
|
|
key={`${stat}-${index}`}
|
|
value={stat}
|
|
index={index}
|
|
exporting={exporting}
|
|
elementIconUrls={guide.elementIconUrls}
|
|
/>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
</div>
|
|
));
|
|
|
|
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 items-center justify-center text-center">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<p className="text-sm font-medium text-primary">
|
|
อาร์ติแฟกต์และสแตทที่แนะนำ
|
|
</p>
|
|
<div className="flex items-center justify-center gap-3">
|
|
<Image
|
|
src="/icon/nav/Artifact.webp"
|
|
alt=""
|
|
width={56}
|
|
height={56}
|
|
loading={exporting ? "eager" : "lazy"}
|
|
className={cn(
|
|
"size-14 shrink-0 object-contain",
|
|
exporting &&
|
|
"drop-shadow-[0_4px_3px_rgba(0,0,0,0.8)]",
|
|
)}
|
|
/>
|
|
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
|
|
Artifacts & Stats
|
|
</h1>
|
|
</div>
|
|
{action}
|
|
</div>
|
|
</header>
|
|
<div
|
|
className={cn(
|
|
"artifact-export-grid relative z-10 grid gap-5",
|
|
exporting ? "grid-cols-[1.2fr_1fr]" : "lg:grid-cols-[1.2fr_1fr]",
|
|
)}
|
|
>
|
|
{" "}
|
|
<div className="artifact-export-left flex flex-col gap-5">
|
|
{exporting ? recommendedCards : (
|
|
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะแนะนำ" labels={recommendedConditions.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`)}>
|
|
{recommendedCards}
|
|
</ConditionSwitcher>
|
|
)}
|
|
<div data-artifact-export-section="tierlist" className="order-first">
|
|
{(() => {
|
|
const tierCards = guide.artifactGroups.map((condition, conditionIndex) => (
|
|
<div key={condition.id} data-tier-condition={conditionIndex} className="artifact-export-tierlist flex flex-col gap-5">
|
|
{exporting && guide.artifactGroups.length > 1 ? <p className="text-sm font-medium">{condition.name || <ExportText th={`เงื่อนไข ${conditionIndex + 1}`} en={`Condition ${conditionIndex + 1}`} />}</p> : null}
|
|
{condition.note ? <p data-export-note className="whitespace-pre-wrap text-sm text-muted-foreground">{condition.note}</p> : null}
|
|
{condition.tiers.map((group) => <Card key={group.id}>
|
|
<CardHeader>
|
|
<CardTitle>
|
|
{group.name === "Late Game"
|
|
? exportLabel(exporting, "เลทเกม", "Late Game")
|
|
: group.name === "Early Game"
|
|
? exportLabel(exporting, "ต้นเกม", "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",
|
|
)}
|
|
>
|
|
<TeamArtifactIcon
|
|
first={row.first}
|
|
second={row.second}
|
|
eager={exporting}
|
|
linked={!exporting}
|
|
sizes="(min-width: 640px) 4rem, 3.5rem"
|
|
className={exporting ? "size-16 shrink-0" : "size-14 shrink-0 sm:size-16"}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium">
|
|
{artifactTierCount(row.second?.name ?? null)}{" "}
|
|
{row.first.name}
|
|
{row.second ? ` + 2 ${row.second.name}` : ""}
|
|
</p>
|
|
{row.note ? (
|
|
<p data-export-note 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, exporting ? "en-US" : undefined)}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>)}
|
|
</div>
|
|
));
|
|
return exporting ? tierCards : <ConditionSwitcher label="เลือกเงื่อนไขอันดับอาร์ติแฟกต์" labels={guide.artifactGroups.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`)}>{tierCards}</ConditionSwitcher>;
|
|
})()}
|
|
</div>
|
|
</div>
|
|
{profile ? (
|
|
<div className="artifact-export-right flex min-w-0 flex-col gap-5">
|
|
<Card>
|
|
<CardHeader>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-5">
|
|
{profile.targetConditions.some((group) => group.rows.length) ? (
|
|
exporting ? (
|
|
profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => (
|
|
<div key={groupIndex} data-artifact-export-section="target-stats" data-target-condition={groupIndex}>
|
|
<p className="mb-2 text-sm font-medium">{exportLabel(exporting, "ค่าสถานะต่ำๆที่ควรจะปั้นไว้", "Recommended Minimum Stats")}</p>
|
|
{group.note ? <p data-export-note className="mb-2 whitespace-pre-wrap text-sm text-muted-foreground">{group.note}</p> : null}
|
|
<div className="flex flex-col gap-1">
|
|
{group.rows.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, exporting,
|
|
)}
|
|
</span>
|
|
</span>
|
|
<strong className="shrink-0">{target.value}</strong>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะเป้าหมาย" labels={profile.targetConditions.filter((group) => group.rows.length).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}>
|
|
{profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => (
|
|
<div key={groupIndex}>
|
|
<p className="mb-2 text-sm font-medium">{exportLabel(exporting, "ค่าสถานะต่ำๆที่ควรจะปั้นไว้", "Recommended Minimum Stats")}</p>
|
|
{group.note ? <p data-export-note className="mb-2 whitespace-pre-wrap text-sm text-muted-foreground">{group.note}</p> : null}
|
|
<div className="flex flex-col gap-1">
|
|
{group.rows.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>
|
|
))}
|
|
</ConditionSwitcher>
|
|
)
|
|
) : null}
|
|
{profile.talents.length ? (
|
|
<div data-artifact-export-section="talents">
|
|
<p className="mb-2 text-sm font-medium">
|
|
{exportLabel(exporting, "ลำดับการอัพพรสวรรค์", "Talent Priority")}
|
|
</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
|
|
? exportLabel(exporting, `เลเวล ${talent.level}+`, `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",
|
|
)}
|
|
>
|
|
{talent.type === "E"
|
|
? exportLabel(exporting, "สกิลธาตุ", "Elemental Skill")
|
|
: talent.type === "Q"
|
|
? exportLabel(exporting, "ท่าไม้ตาย", "Elemental Burst")
|
|
: exportLabel(exporting, "ตีปกติ", "Normal Attack")}
|
|
</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 data-export-note data-artifact-export-section="note" className="pt-3">
|
|
<p className="mb-2 text-sm font-medium">{exportLabel(exporting, "โน็ต", "Notes")}</p>
|
|
<p className="text-sm leading-6 text-muted-foreground">
|
|
{profile.note}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const thaiStats: 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%": "โบนัสการรักษา",
|
|
};
|
|
|
|
const englishStats: Record<string, string> = {
|
|
HP: "HP",
|
|
ATK: "ATK",
|
|
DEF: "DEF",
|
|
EM: "Elemental Mastery",
|
|
ER: "Energy Recharge",
|
|
"CRIT RATE": "CRIT Rate",
|
|
"CRIT DAMAGE": "CRIT DMG",
|
|
"CRIT Rate/CRIT DMG": "CRIT Rate / CRIT DMG",
|
|
};
|
|
|
|
const statLabelAliases: Record<string, string> = {
|
|
"CRIT Rate%": "CRIT RATE",
|
|
"CRIT DMG%": "CRIT DAMAGE",
|
|
"CRIT Rate%/CRIT DMG%": "CRIT Rate/CRIT DMG",
|
|
};
|
|
|
|
function statLabel(value: string, exporting = false): ReactNode {
|
|
const labelKey = statLabelAliases[value] ?? value;
|
|
if (!exporting) return thaiStats[labelKey] ?? value;
|
|
return (
|
|
<ExportText
|
|
th={thaiStats[labelKey] ?? value}
|
|
en={englishStats[labelKey] ?? value}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ArtifactStat({
|
|
iconSrc,
|
|
label,
|
|
values,
|
|
exporting = false,
|
|
elementIconUrls,
|
|
}: {
|
|
iconSrc: string;
|
|
label: ReactNode;
|
|
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="wrap-break-word">{statLabel(value, exporting)}</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>
|
|
<div className="flex items-center justify-center gap-3">
|
|
<Image
|
|
src="/icon/nav/Constellations.webp"
|
|
alt=""
|
|
width={56}
|
|
height={56}
|
|
loading="lazy"
|
|
className="size-14 shrink-0 object-contain"
|
|
/>
|
|
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
|
|
Constellations
|
|
</h1>
|
|
</div>
|
|
{note ? (
|
|
<p className="max-w-3xl whitespace-pre-wrap text-muted-foreground">
|
|
{note}
|
|
</p>
|
|
) : null}
|
|
</header>
|
|
<ConditionSwitcher label="เลือกเงื่อนไขกลุ่มดาว" labels={guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}>
|
|
{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 ? `-${group.name || `Condition-${groupIndex + 1}`}` : ""}.png`}
|
|
width={1500}
|
|
height={1500}
|
|
captureClassName="p-0"
|
|
showLanguageSelector={false}
|
|
promptForLanguage
|
|
>
|
|
<ConstellationImage
|
|
guide={guide}
|
|
group={group}
|
|
/>
|
|
</GuideImageExport>
|
|
</div>
|
|
{groups.length > 1 ? <h2 className="font-heading text-2xl font-semibold">{group.name || `เงื่อนไข ${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>
|
|
))}
|
|
</ConditionSwitcher>
|
|
</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">Team Calculation</h1>
|
|
</header>
|
|
<TeamSearch groups={guide.teamGroups} />
|
|
</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 sm:grid-cols-4">
|
|
{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">{exportLabel(exporting, "ไม่พบรายการ", "Item unavailable")}</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, exporting ? "en-US" : undefined)}</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>
|
|
);
|
|
}
|