669 lines
26 KiB
TypeScript
669 lines
26 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import {
|
|
Image,
|
|
GuideExportBranding,
|
|
Card,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardContent,
|
|
Badge,
|
|
Separator,
|
|
ConditionSwitcher,
|
|
} from "./primitives";
|
|
import { TeamArtifactIcon } from "./team";
|
|
import type { PublicGuide } from "@/lib/guides/queries";
|
|
import { cn } from "@/lib/utils";
|
|
import { artifactTierCount } from "@/lib/guides/artifacts";
|
|
import { elementIconFilter } from "@/lib/catalog/element";
|
|
import {
|
|
normalizeTargetStat,
|
|
recommendedStatElement,
|
|
recommendedStatIconName,
|
|
statPriorityColorClass,
|
|
targetStatIconName,
|
|
} from "@/lib/guides/stats";
|
|
import {
|
|
normalizeTalentPriorities,
|
|
talentRelationSymbol,
|
|
} from "@/lib/guides/talents";
|
|
function metric(value: string | null, locale?: string) {
|
|
return value === null
|
|
? null
|
|
: `${Number(value).toLocaleString(locale, { maximumFractionDigits: 2 })}%`;
|
|
}
|
|
export function ArtifactSectionContent({
|
|
guide,
|
|
action,
|
|
exporting = true,
|
|
}: {
|
|
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 || `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">
|
|
{exporting ? "Best Main Stats" : "ออฟหลักที่ดีที่สุด"}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid grid-cols-3 p-0">
|
|
<ArtifactStat
|
|
iconSrc="/icon/Icon_Sands_of_Eon.webp"
|
|
label={exporting ? "Sands" : "นาฬิกา"}
|
|
values={condition.sands}
|
|
exporting={exporting}
|
|
elementIconUrls={guide.elementIconUrls}
|
|
/>
|
|
<ArtifactStat
|
|
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp"
|
|
label={exporting ? "Goblet" : "ถ้วย"}
|
|
values={condition.goblet}
|
|
exporting={exporting}
|
|
elementIconUrls={guide.elementIconUrls}
|
|
/>
|
|
<ArtifactStat
|
|
iconSrc="/icon/Icon_Circlet_of_Logos.webp"
|
|
label={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>
|
|
{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(
|
|
"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">
|
|
{exporting
|
|
? "Recommended Artifacts & Stats"
|
|
: "อาร์ติแฟกต์และสแตทที่แนะนำ"}
|
|
</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">
|
|
{exporting ? "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="artifact-export-tierlist order-first flex flex-col gap-5"
|
|
>
|
|
{guide.artifactGroups.map((group) => (
|
|
<Card key={group.id}>
|
|
<CardHeader>
|
|
<CardTitle>
|
|
{group.name === "Late Game"
|
|
? exporting
|
|
? "Late Game"
|
|
: "เลทเกม"
|
|
: group.name === "Early Game"
|
|
? 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>
|
|
</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">
|
|
{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">
|
|
{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">
|
|
{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
|
|
? exporting
|
|
? `Level ${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",
|
|
)}
|
|
>
|
|
{talent.type === "E"
|
|
? exporting
|
|
? "Elemental Skill"
|
|
: "สกิลธาตุ"
|
|
: talent.type === "Q"
|
|
? exporting
|
|
? "Elemental Burst"
|
|
: "ท่าไม้ตาย"
|
|
: 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">
|
|
{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",
|
|
};
|
|
|
|
function statLabel(value: string, exporting = true): string {
|
|
return (exporting ? englishStats : thaiStats)[value] ?? value;
|
|
}
|
|
|
|
function ArtifactStat({
|
|
iconSrc,
|
|
label,
|
|
values,
|
|
exporting = true,
|
|
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 = true,
|
|
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) && iconName !== "elemental-bonus";
|
|
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, exporting)}</span>
|
|
</span>
|
|
);
|
|
}
|